feat(sources): admin page to add and remove git sources (TODO.md L4)
This commit is contained in:
+11
-4
@@ -40,12 +40,19 @@ BOR_RRF_K=60 # Reciprocal Rank Fusion damping constant
|
|||||||
# BOR_IMPORT_EXTENSIONS=md,markdown,txt,yaml,yml,json,py
|
# BOR_IMPORT_EXTENSIONS=md,markdown,txt,yaml,yml,json,py
|
||||||
# BOR_SUGGESTIONS=["How is my Kubernetes cluster set up?"] # JSON list of onboarding chips
|
# BOR_SUGGESTIONS=["How is my Kubernetes cluster set up?"] # JSON list of onboarding chips
|
||||||
|
|
||||||
# --- Import sources (git; phase 28) ---
|
# --- Import sources (git; phase 28, admin-managed since phase 35) ---
|
||||||
# Comma-separated git repo URLs; import_docs clones each (first run) or
|
# Comma-separated git repo URLs; import_docs clones each (first run) or
|
||||||
# pulls it (subsequent runs) into BOR_SOURCES_DIR/<repo-name>/ and indexes
|
# pulls it (subsequent runs) into BOR_SOURCES_DIR/<repo-name>/ and indexes
|
||||||
# the result. Empty = no git sources (import_docs falls back to --source /
|
# the result. Auth via URL (e.g. an https token) or SSH keys.
|
||||||
# the old ~/Homelab + ~/Deployments defaults). Auth via URL (e.g. an
|
#
|
||||||
# https token) or SSH keys.
|
# Phase 35 (owner permission 2026-08-26): the PRIMARY way to manage the
|
||||||
|
# list is the admin Git sources page (http://localhost:8000/git-sources.html)
|
||||||
|
# — rows stored in Postgres (git_sources table, migration 0006). This
|
||||||
|
# variable is the EMPTY-TABLE FALLBACK: it only applies while the admin
|
||||||
|
# list is empty; once the page has stored any source, this variable is
|
||||||
|
# ignored (the page is the source of truth). Empty table + empty variable
|
||||||
|
# = no git sources (import_docs falls back to --source / the old
|
||||||
|
# ~/Homelab + ~/Deployments defaults; the UI Sync button fails loudly).
|
||||||
# BOR_GIT_SOURCES=https://github.com/user/homelab.git,https://github.com/user/deployments.git
|
# BOR_GIT_SOURCES=https://github.com/user/homelab.git,https://github.com/user/deployments.git
|
||||||
# BOR_SOURCES_DIR=~/bor-sources
|
# BOR_SOURCES_DIR=~/bor-sources
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -19,9 +19,10 @@ RUN mkdir -p /out/assets \
|
|||||||
&& esbuild ./assets/document.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/document.js \
|
&& esbuild ./assets/document.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/document.js \
|
||||||
&& esbuild ./assets/login.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/login.js \
|
&& esbuild ./assets/login.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/login.js \
|
||||||
&& esbuild ./assets/tuning.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/tuning.js \
|
&& esbuild ./assets/tuning.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/tuning.js \
|
||||||
|
&& esbuild ./assets/git-sources.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/git-sources.js \
|
||||||
&& esbuild ./assets/markdown.js --minify --outfile=/out/assets/markdown.js \
|
&& esbuild ./assets/markdown.js --minify --outfile=/out/assets/markdown.js \
|
||||||
&& esbuild ./assets/styles.css --minify --outfile=/out/assets/styles.css \
|
&& esbuild ./assets/styles.css --minify --outfile=/out/assets/styles.css \
|
||||||
&& cp ./index.html ./sources.html ./document.html ./login.html ./tuning.html /out/
|
&& cp ./index.html ./sources.html ./document.html ./login.html ./tuning.html ./git-sources.html /out/
|
||||||
|
|
||||||
# ---------- Stage 2: python dependencies ----------
|
# ---------- Stage 2: python dependencies ----------
|
||||||
FROM docker.io/python:3.12-slim AS python
|
FROM docker.io/python:3.12-slim AS python
|
||||||
|
|||||||
@@ -65,16 +65,21 @@ uv run python -m scripts.import_docs # import the configured sources (below)
|
|||||||
|
|
||||||
Two source modes:
|
Two source modes:
|
||||||
|
|
||||||
- **Git sources (recommended)** — set `BOR_GIT_SOURCES` in `.env` to a
|
- **Git sources (recommended)** — the list is managed on the **admin
|
||||||
comma-separated list of git repo URLs. `import_docs` clones each repo
|
Git sources page** (`/git-sources.html`) and stored in Postgres (see
|
||||||
|
[Git-based sources](#git-based-sources)). `import_docs` clones each repo
|
||||||
(first run) or pulls it (subsequent runs) into
|
(first run) or pulls it (subsequent runs) into
|
||||||
`BOR_SOURCES_DIR/<repo-name>/` (default `~/bor-sources`) and indexes the
|
`BOR_SOURCES_DIR/<repo-name>/` (default `~/bor-sources`) and indexes the
|
||||||
checkouts — see [Git-based sources](#git-based-sources).
|
checkouts. While the stored list is empty, the `BOR_GIT_SOURCES` variable
|
||||||
|
in `.env` is the fallback — the moment the page stores a source, the
|
||||||
|
variable is ignored.
|
||||||
- **Manual directories** — `--source <path>` (repeatable) imports local
|
- **Manual directories** — `--source <path>` (repeatable) imports local
|
||||||
directories directly and *always wins* over `BOR_GIT_SOURCES`.
|
directories directly and *always wins* over the git sources (stored list
|
||||||
- If neither is set, the import falls back to the **previous** default,
|
or env).
|
||||||
|
- If neither is set (stored list, `--source`, and `BOR_GIT_SOURCES` all
|
||||||
|
empty), the import falls back to the **previous** default,
|
||||||
`~/Homelab` + `~/Deployments` — kept only for backwards compatibility,
|
`~/Homelab` + `~/Deployments` — kept only for backwards compatibility,
|
||||||
now replaced by `BOR_GIT_SOURCES`.
|
now replaced by the git sources list.
|
||||||
|
|
||||||
### 6. Run the app
|
### 6. Run the app
|
||||||
```bash
|
```bash
|
||||||
@@ -102,6 +107,12 @@ uv run uvicorn app.main:app --reload
|
|||||||
new tab). **Admin-only** — anonymous visitors see a sign-in gate instead
|
new tab). **Admin-only** — anonymous visitors see a sign-in gate instead
|
||||||
(the catalog is what the login locks; the document viewer itself stays
|
(the catalog is what the login locks; the document viewer itself stays
|
||||||
open to everyone).
|
open to everyone).
|
||||||
|
- **Git sources** (`/git-sources.html`) — the list of git repositories the
|
||||||
|
**Sync sources** button clones and indexes; **admin-only** (the same
|
||||||
|
sign-in gate as Sources). Add or remove repositories here — no `.env`
|
||||||
|
editing, no restart. Adding/removing does not clone or prune on its
|
||||||
|
own: the Sync button performs that, and a removed repository's documents
|
||||||
|
leave the index on the next sync.
|
||||||
|
|
||||||
## Thinking
|
## Thinking
|
||||||
|
|
||||||
@@ -262,26 +273,38 @@ embedded.
|
|||||||
|
|
||||||
Rather than pointing the import at local folders, point it at **git
|
Rather than pointing the import at local folders, point it at **git
|
||||||
repositories** — the notes live in the repos and `import_docs` keeps local
|
repositories** — the notes live in the repos and `import_docs` keeps local
|
||||||
checkouts of them up to date for you:
|
checkouts of them up to date for you.
|
||||||
|
|
||||||
|
**Where the list lives (phase 35):** the primary management surface is
|
||||||
|
the **admin Git sources page** (`/git-sources.html`) — add or remove
|
||||||
|
repositories there and the list is stored in Postgres (the `git_sources`
|
||||||
|
table). `BOR_GIT_SOURCES` in `.env` is the **empty-table fallback**: it
|
||||||
|
only applies while the stored list is empty, and is ignored once the page
|
||||||
|
has any row (the page becomes the source of truth — no `.env` editing, no
|
||||||
|
restart needed afterwards).
|
||||||
|
|
||||||
```env
|
```env
|
||||||
# .env
|
# .env — the fallback list (fresh setups, or until the admin page
|
||||||
|
# stores a source; phase 35 demotes this variable, it does not remove it)
|
||||||
BOR_GIT_SOURCES=https://git.reeseapps.com/reese/homelab.git,git@github.com:reese/deployments.git
|
BOR_GIT_SOURCES=https://git.reeseapps.com/reese/homelab.git,git@github.com:reese/deployments.git
|
||||||
BOR_SOURCES_DIR=~/bor-sources # default; each repo lands in <dir>/<repo-name>/
|
BOR_SOURCES_DIR=~/bor-sources # default; each repo lands in <dir>/<repo-name>/
|
||||||
```
|
```
|
||||||
|
|
||||||
- `BOR_GIT_SOURCES` is a **comma-separated list** of URLs. Auth is whatever
|
- The effective list (stored rows, else `BOR_GIT_SOURCES` while the stored
|
||||||
the machine supplies — `https://…` via the OS credential helper, or
|
list is empty) is a set of git repo URLs. Auth is whatever the machine
|
||||||
`git@host:repo.git` via your SSH key; no credentials are stored in the
|
supplies — `https://…` via the OS credential helper, or `git@host:repo.git`
|
||||||
app or `.env`.
|
via your SSH key; no credentials are stored in the app or `.env`.
|
||||||
|
Stored URLs are shape-validated on the page (`https://`, `ssh://`,
|
||||||
|
`git@…` — scp-style `host:repo` is rejected).
|
||||||
- Every run **clones** each repo (first time, shallow `--depth 1`) or
|
- Every run **clones** each repo (first time, shallow `--depth 1`) or
|
||||||
**pulls** it (`git pull --ff-only` — fast-forward only, so a diverged or
|
**pulls** it (`git pull --ff-only` — fast-forward only, so a diverged or
|
||||||
broken checkout fails loudly instead of merging) into
|
broken checkout fails loudly instead of merging) into
|
||||||
`BOR_SOURCES_DIR/<repo-name>/`, then indexes the checkouts exactly like
|
`BOR_SOURCES_DIR/<repo-name>/`, then indexes the checkouts exactly like
|
||||||
any local directory (A9 format filter, hidden-dir skip, sha256 delta).
|
any local directory (A9 format filter, hidden-dir skip, sha256 delta).
|
||||||
`documents.source` is the repo directory name (e.g. `homelab`).
|
`documents.source` is the repo directory name (e.g. `homelab`).
|
||||||
- **`--source <path>` overrides**: when the flag is given,
|
- **`--source <path>` overrides**: when the flag is given, the git
|
||||||
`BOR_GIT_SOURCES` is ignored and the manual directory(ies) are imported.
|
sources (stored list *and* `BOR_GIT_SOURCES`) are ignored and the
|
||||||
|
manual directory(ies) are imported.
|
||||||
- **A failed sync aborts the run**: if any repo cannot be cloned/pulled,
|
- **A failed sync aborts the run**: if any repo cannot be cloned/pulled,
|
||||||
`import_docs` exits non-zero naming the failing repo and imports
|
`import_docs` exits non-zero naming the failing repo and imports
|
||||||
**nothing** (no partial junk). Fix the URL/connectivity and re-run — the
|
**nothing** (no partial junk). Fix the URL/connectivity and re-run — the
|
||||||
@@ -293,9 +316,10 @@ The **Sync sources** button on the **Sources** page — visible to the
|
|||||||
**admin only** (anonymous visitors never see it) — runs the whole
|
**admin only** (anonymous visitors never see it) — runs the whole
|
||||||
git-source refresh in one click, in-process:
|
git-source refresh in one click, in-process:
|
||||||
|
|
||||||
1. **clone/pull** every `BOR_GIT_SOURCES` repo (the same
|
1. **clone/pull** every configured git source — the admin-managed list
|
||||||
`clone_or_pull` the CLI uses — shallow clone on first run,
|
(the `git_sources` table; `BOR_GIT_SOURCES` only while that list is
|
||||||
`git pull --ff-only` afterwards);
|
empty), through the same `clone_or_pull` the CLI uses (shallow clone
|
||||||
|
on first run, `git pull --ff-only` afterwards);
|
||||||
2. **re-import with prune** — the `--prune` equivalent, so files deleted
|
2. **re-import with prune** — the `--prune` equivalent, so files deleted
|
||||||
upstream leave the index (the button is the canonical "mirror the
|
upstream leave the index (the button is the canonical "mirror the
|
||||||
repos" action); the sha256 delta still skips unchanged files, so an
|
repos" action); the sha256 delta still skips unchanged files, so an
|
||||||
@@ -304,10 +328,12 @@ git-source refresh in one click, in-process:
|
|||||||
chat turn injects) — but only when the import actually changed the
|
chat turn injects) — but only when the import actually changed the
|
||||||
knowledge base.
|
knowledge base.
|
||||||
|
|
||||||
- **Prerequisites:** `BOR_GIT_SOURCES` must be set — an unset/empty list
|
- **Prerequisites:** at least one git source must be configured — a row
|
||||||
fails the sync loudly ("no git sources configured"), because the button
|
on the admin Git sources page, or `BOR_GIT_SOURCES` in `.env` while the
|
||||||
targets the git repos only (manual `--source` directories have no repo
|
stored list is empty; **both** empty fails the sync loudly ("no git
|
||||||
to clone) — and `git` must be on the app's `PATH`.
|
sources configured"), because the button targets the git repos only
|
||||||
|
(manual `--source` directories have no repo to clone) — and `git` must
|
||||||
|
be on the app's `PATH`.
|
||||||
- **States:** clicking starts the run (`202`) and the button goes
|
- **States:** clicking starts the run (`202`) and the button goes
|
||||||
disabled with **Syncing…** (spinning icon) while the page polls
|
disabled with **Syncing…** (spinning icon) while the page polls
|
||||||
`GET /api/sync/status` every 2 s. There is deliberately **no
|
`GET /api/sync/status` every 2 s. There is deliberately **no
|
||||||
@@ -534,8 +560,8 @@ served locally (no CDN), `BOR_ENVIRONMENT=production`.
|
|||||||
| `BOR_HYBRID_LEXICAL_CANDIDATES` | `30` | FTS list width for the RRF fusion |
|
| `BOR_HYBRID_LEXICAL_CANDIDATES` | `30` | FTS list width for the RRF fusion |
|
||||||
| `BOR_RRF_K` | `60` | RRF damping constant (`1/(k + rank)`) |
|
| `BOR_RRF_K` | `60` | RRF damping constant (`1/(k + rank)`) |
|
||||||
| `BOR_IMPORT_EXTENSIONS` | `md,markdown,txt,yaml,yml,json,py` | csv of importable formats (may only narrow the A9 set) |
|
| `BOR_IMPORT_EXTENSIONS` | `md,markdown,txt,yaml,yml,json,py` | csv of importable formats (may only narrow the A9 set) |
|
||||||
| `BOR_GIT_SOURCES` | — (empty) | csv of git repo URLs; `import_docs` clones/pulls them into `BOR_SOURCES_DIR` and indexes the checkouts (see *Git-based sources*) |
|
| `BOR_GIT_SOURCES` | — (empty) | csv of git repo URLs — **fallback while the admin Git sources page's list (Postgres `git_sources`) is empty**; the page is the primary management surface (see *Git-based sources*) |
|
||||||
| `BOR_SOURCES_DIR` | `~/bor-sources` | where the `BOR_GIT_SOURCES` repos are cloned/pulled (one subdirectory per repo) |
|
| `BOR_SOURCES_DIR` | `~/bor-sources` | where the git source repos are cloned/pulled (one subdirectory per repo) |
|
||||||
| `BOR_STEERING_MAX_CHARS` | `8000` | char budget for the `<tuning>` (steering notes) prompt section |
|
| `BOR_STEERING_MAX_CHARS` | `8000` | char budget for the `<tuning>` (steering notes) prompt section |
|
||||||
| `BOR_SUMMARY_MAX_CHARS` | `12000` | cap on document content sent to the `lite` summary model at import (see *Document summaries*) |
|
| `BOR_SUMMARY_MAX_CHARS` | `12000` | cap on document content sent to the `lite` summary model at import (see *Document summaries*) |
|
||||||
| `BOR_KB_OVERVIEW_MAX_CHARS` | `4000` | char budget for the `<knowledge_base>` (KB overview) prompt section |
|
| `BOR_KB_OVERVIEW_MAX_CHARS` | `4000` | char budget for the `<knowledge_base>` (KB overview) prompt section |
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""git sources: admin-managed list of git repo URLs
|
||||||
|
|
||||||
|
Revision ID: 0006
|
||||||
|
Revises: 0005
|
||||||
|
Create Date: 2026-08-26
|
||||||
|
|
||||||
|
Phase 35 (git-sources-admin story, A13 — one additive, reversible table,
|
||||||
|
no other schema change):
|
||||||
|
|
||||||
|
* ``git_sources`` — one row per admin-managed git source URL. The admin
|
||||||
|
page (``/git-sources.html``) maintains the list that the Sync button
|
||||||
|
(phase 32) and ``import_docs`` (phase 28) clone/pull. DB rows win over
|
||||||
|
the ``BOR_GIT_SOURCES`` env var, which stays a fallback while the table
|
||||||
|
is empty (once the table has rows it is ignored — the UI is the source
|
||||||
|
of truth). ``url`` is unique (``uq_git_sources_url``); ``added_at`` is
|
||||||
|
stamped server-side and orders the list oldest-first.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0006"
|
||||||
|
down_revision = "0005"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"git_sources",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("url", sa.Text(), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"added_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.func.now(),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.create_index("uq_git_sources_url", "git_sources", ["url"], unique=True)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("uq_git_sources_url", table_name="git_sources")
|
||||||
|
op.drop_table("git_sources")
|
||||||
@@ -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
@@ -15,9 +15,12 @@ stale button state (§7.4 adaptation, phase locked decisions).
|
|||||||
Pipeline (the canonical "mirror the repos" action — phase locked
|
Pipeline (the canonical "mirror the repos" action — phase locked
|
||||||
decisions):
|
decisions):
|
||||||
|
|
||||||
1. resolve the ``BOR_GIT_SOURCES`` URLs — empty/missing fails loudly
|
1. resolve the effective git sources — the ``git_sources`` DB rows,
|
||||||
(``no git sources configured``) instead of silently importing the
|
else the ``BOR_GIT_SOURCES`` fallback
|
||||||
legacy local directories;
|
(: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
|
2. :func:`scripts.git_sync.clone_or_pull` each repo into
|
||||||
``BOR_SOURCES_DIR/<repo-name>/`` (phase 28 — reused, not
|
``BOR_SOURCES_DIR/<repo-name>/`` (phase 28 — reused, not
|
||||||
re-implemented; a failing repo aborts before any import);
|
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.config import get_settings
|
||||||
from app.core.auth import require_admin
|
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.importer import ImportSummary, import_sources
|
||||||
from app.rag.llm import LLMClient
|
from app.rag.llm import LLMClient
|
||||||
from app.rag.overview import regenerate_overview
|
from app.rag.overview import regenerate_overview
|
||||||
@@ -141,13 +146,23 @@ async def _run_sync() -> None:
|
|||||||
_status.error = None
|
_status.error = None
|
||||||
try:
|
try:
|
||||||
settings = get_settings()
|
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:
|
if not git_urls:
|
||||||
# The button targets BOR_GIT_SOURCES only (manual --source
|
# The button targets git sources only (manual --source dirs
|
||||||
# dirs have no repo to clone) — an empty config fails loudly
|
# have no repo to clone) — an empty config on *both* origins
|
||||||
# instead of silently importing the legacy directories.
|
# fails loudly instead of silently importing the legacy
|
||||||
raise GitSyncError("no git sources configured (BOR_GIT_SOURCES)")
|
# directories.
|
||||||
logger.info("sync: started repos=%d", len(git_urls))
|
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_root = Path(settings.sources_dir).expanduser()
|
||||||
sources = [clone_or_pull(url, sources_root / repo_name(url)) for url in git_urls]
|
sources = [clone_or_pull(url, sources_root / repo_name(url)) for url in git_urls]
|
||||||
llm = LLMClient()
|
llm = LLMClient()
|
||||||
|
|||||||
+5
-1
@@ -111,7 +111,10 @@ def asset_version(static_dir: str | None = None) -> str:
|
|||||||
# Response-layer cache busting (task 02)
|
# 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, ...] = (
|
HTML_PAGES: tuple[str, ...] = (
|
||||||
"/",
|
"/",
|
||||||
"/index.html",
|
"/index.html",
|
||||||
@@ -119,6 +122,7 @@ HTML_PAGES: tuple[str, ...] = (
|
|||||||
"/document.html",
|
"/document.html",
|
||||||
"/login.html",
|
"/login.html",
|
||||||
"/tuning.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
|
#: Prefix of the versioned static assets (header-only caching; the body is
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from starlette.middleware.sessions import SessionMiddleware
|
|||||||
from app.api.auth import router as auth_router
|
from app.api.auth import router as auth_router
|
||||||
from app.api.chat import router as chat_router
|
from app.api.chat import router as chat_router
|
||||||
from app.api.docs import router as docs_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.health import router as health_router
|
||||||
from app.api.steering import router as steering_router
|
from app.api.steering import router as steering_router
|
||||||
from app.api.suggestions import router as suggestions_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(auth_router, prefix="/api")
|
||||||
app.include_router(suggestions_router, prefix="/api")
|
app.include_router(suggestions_router, prefix="/api")
|
||||||
app.include_router(docs_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(chat_router, prefix="/api")
|
||||||
app.include_router(steering_router, prefix="/api")
|
app.include_router(steering_router, prefix="/api")
|
||||||
app.include_router(sync_router, prefix="/api")
|
app.include_router(sync_router, prefix="/api")
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ Data model — see ``.agent/PLAN.md`` §Data Model:
|
|||||||
* ``kb_overview`` — single-row lite-generated outline of the KB's basic
|
* ``kb_overview`` — single-row lite-generated outline of the KB's basic
|
||||||
categories, injected as the ``<knowledge_base>``
|
categories, injected as the ``<knowledge_base>``
|
||||||
section of every chat turn (phase 31).
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -133,3 +135,19 @@ class KbOverview(Base):
|
|||||||
updated_at: Mapped[datetime] = mapped_column(
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), server_default=func.now()
|
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())
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -154,3 +154,46 @@ class SteeringNoteList(BaseModel):
|
|||||||
"""``GET /api/steering`` response: all notes, newest first."""
|
"""``GET /api/steering`` response: all notes, newest first."""
|
||||||
|
|
||||||
notes: list[SteeringNote]
|
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
|
||||||
|
|||||||
@@ -0,0 +1,320 @@
|
|||||||
|
/* Brain of Reese — Git sources admin page (phase 35, task 04).
|
||||||
|
*
|
||||||
|
* The page module for /git-sources.html: the admin-only manager for the
|
||||||
|
* stored git source list (git-sources table, phase 35 tasks 01/02).
|
||||||
|
* This module is the single owner of the page's behaviour:
|
||||||
|
*
|
||||||
|
* • boot — initSharedHeader() (one cached whoami, shared with the
|
||||||
|
* header toggling): anonymous → the sign-in gate shows and the
|
||||||
|
* manager stays hidden (the exact Sources page gate pattern, and
|
||||||
|
* NO /api/git-sources call is made); admin → gate hidden,
|
||||||
|
* #git-sources-content revealed, loadSources().
|
||||||
|
* • loadSources() — GET /api/git-sources → the table rows
|
||||||
|
* (#git-sources-tbody), the env-fallback note's visibility
|
||||||
|
* (from_env), and the empty state. URLs are ALWAYS rendered with
|
||||||
|
* textContent — never innerHTML (they may embed user:pass@
|
||||||
|
* credentials; phase 32's masking discipline). Non-2xx or a
|
||||||
|
* network failure renders the role="alert" load error with a
|
||||||
|
* retry button — never a stuck page.
|
||||||
|
* • add — #git-source-form submit → POST /api/git-sources {url}.
|
||||||
|
* §7.4 never-stale: the button disables + relabels "Adding…"
|
||||||
|
* while the request is out, re-enables ("Add source") on success
|
||||||
|
* AND failure. 201 clears the input, reloads the list, and
|
||||||
|
* focuses the new row's Remove button (a11y); a failure (409
|
||||||
|
* duplicate, 422 shape) shows the server detail inline under the
|
||||||
|
* form (role="alert", 422 shape-aware like the tuning forms) and
|
||||||
|
* keeps the input — the instruction survives.
|
||||||
|
* • remove — a row's Remove button asks window.confirm first
|
||||||
|
* (removal prunes the documents only on the NEXT sync — the
|
||||||
|
* confirm says so). Cancel → nothing; ok → the row button
|
||||||
|
* disables, DELETE /api/git-sources/{id}, loadSources(). A
|
||||||
|
* failure shows a per-row role="alert" error and re-enables the
|
||||||
|
* button. Env-fallback rows (id null — the list comes from
|
||||||
|
* BOR_GIT_SOURCES, not the table) carry no Remove: nothing is
|
||||||
|
* stored to remove — they show a "from .env" tag instead.
|
||||||
|
* • announce(msg) — #git-sources-announcer (role=status,
|
||||||
|
* aria-live=polite): the screen-reader confirmation for loads,
|
||||||
|
* adds, and removals.
|
||||||
|
*
|
||||||
|
* Scope boundary (phase locked decisions): adding or removing a repo
|
||||||
|
* does NOT clone, import, or prune — the header's Sync sources button
|
||||||
|
* (module-owned in assets/header.js) performs that; the page's hint
|
||||||
|
* box says so.
|
||||||
|
*
|
||||||
|
* The shared header module loads through this script's own relative
|
||||||
|
* import ("./header.js") — a hoisted import evaluated before this body
|
||||||
|
* runs (single-evaluation design: no direct <script> tag; esbuild
|
||||||
|
* inlines it into the page bundle in the image build).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { initSharedHeader } from "./header.js";
|
||||||
|
|
||||||
|
/* ---------- page elements (git-sources.html, task 04) ---------- */
|
||||||
|
const gateEl = document.querySelector("#git-sources-gate");
|
||||||
|
const contentEl = document.querySelector("#git-sources-content");
|
||||||
|
const formEl = document.querySelector("#git-source-form");
|
||||||
|
const urlInput = document.querySelector("#git-source-url");
|
||||||
|
const addBtn = document.querySelector("#git-source-add");
|
||||||
|
const addError = document.querySelector("#git-source-error");
|
||||||
|
const loadErrorEl = document.querySelector("#git-sources-load-error");
|
||||||
|
const loadErrorText = document.querySelector("#git-sources-load-error-text");
|
||||||
|
const retryBtn = document.querySelector("#git-sources-retry");
|
||||||
|
const tableWrap = document.querySelector("#git-sources-table-wrap");
|
||||||
|
const tbody = document.querySelector("#git-sources-tbody");
|
||||||
|
const emptyEl = document.querySelector("#git-sources-empty");
|
||||||
|
const envNote = document.querySelector("#git-sources-env-note");
|
||||||
|
const announcer = document.querySelector("#git-sources-announcer");
|
||||||
|
|
||||||
|
/* Polite live region: the screen-reader confirmation for loads, adds,
|
||||||
|
and removals (the phase-15 announcer pattern). */
|
||||||
|
function announce(message) {
|
||||||
|
if (announcer) announcer.textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Added date — localized (toLocaleString); env-fallback rows carry
|
||||||
|
added_at null, and a corrupt timestamp must not blank the cell. */
|
||||||
|
function fmtDate(iso) {
|
||||||
|
if (!iso) return "—";
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleString();
|
||||||
|
} catch {
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* FastAPI error bodies: a string detail or the validation-error array
|
||||||
|
(the first entry's msg is the human line). Same extraction as
|
||||||
|
tuning.js — 422 shape-aware. */
|
||||||
|
async function apiDetail(r, fallback) {
|
||||||
|
try {
|
||||||
|
const data = await r.json();
|
||||||
|
if (Array.isArray(data.detail) && data.detail[0] && data.detail[0].msg) {
|
||||||
|
return String(data.detail[0].msg);
|
||||||
|
}
|
||||||
|
if (typeof data.detail === "string" && data.detail) return data.detail;
|
||||||
|
} catch {
|
||||||
|
/* non-JSON error body */
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- load / render ---------- */
|
||||||
|
|
||||||
|
/* GET /api/git-sources → the table rows + env note + empty state.
|
||||||
|
The load error (role="alert" + retry) is the ONLY terminal state a
|
||||||
|
failed fetch may reach — never a stuck page. */
|
||||||
|
async function loadSources() {
|
||||||
|
let r;
|
||||||
|
try {
|
||||||
|
r = await fetch("/api/git-sources");
|
||||||
|
} catch {
|
||||||
|
showLoadError("Could not reach the server — is the app running?");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!r.ok) {
|
||||||
|
showLoadError(await apiDetail(r, `The server could not list the git sources (${r.status}).`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = await r.json();
|
||||||
|
} catch {
|
||||||
|
showLoadError("The server sent an unreadable list — try again.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hideLoadError();
|
||||||
|
const sources = Array.isArray(data.sources) ? data.sources : [];
|
||||||
|
renderSources(sources, data.from_env === true);
|
||||||
|
announce(`${sources.length} git source${sources.length === 1 ? "" : "s"} listed.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLoadError(message) {
|
||||||
|
if (loadErrorText) loadErrorText.textContent = message;
|
||||||
|
if (loadErrorEl) loadErrorEl.hidden = false;
|
||||||
|
// The list state is unknown — hide the table AND the empty state so
|
||||||
|
// the error is the only claim about the list's contents.
|
||||||
|
if (tableWrap) tableWrap.hidden = true;
|
||||||
|
if (emptyEl) emptyEl.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideLoadError() {
|
||||||
|
if (loadErrorEl) loadErrorEl.hidden = true;
|
||||||
|
if (loadErrorText) loadErrorText.textContent = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSources(sources, fromEnv) {
|
||||||
|
if (envNote) envNote.hidden = !fromEnv;
|
||||||
|
if (tbody) {
|
||||||
|
tbody.replaceChildren();
|
||||||
|
for (const s of sources) tbody.appendChild(makeRow(s));
|
||||||
|
}
|
||||||
|
const hasRows = sources.length > 0;
|
||||||
|
if (tableWrap) tableWrap.hidden = !hasRows;
|
||||||
|
if (emptyEl) emptyEl.hidden = hasRows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* One row: the URL in a mono <code> (textContent only — URLs may
|
||||||
|
contain credentials), the localized added date ("—" for env
|
||||||
|
fallback rows), and the per-row Remove button — or the "from .env"
|
||||||
|
tag for env-fallback rows (id null: nothing is stored to remove;
|
||||||
|
the env note says where the active list comes from). */
|
||||||
|
const REMOVE_ICON =
|
||||||
|
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M5 7h14M10 7V5h4v2M8.5 7l.7 12h5.6l.7-12"/></svg>';
|
||||||
|
|
||||||
|
function makeRow(s) {
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
if (s.id) tr.dataset.id = s.id;
|
||||||
|
|
||||||
|
const urlTd = document.createElement("td");
|
||||||
|
urlTd.className = "git-source-url-cell";
|
||||||
|
urlTd.title = s.url; // full URL on hover (long URLs scroll the wrapper)
|
||||||
|
const code = document.createElement("code");
|
||||||
|
code.textContent = s.url; // rendered as text, never as HTML
|
||||||
|
urlTd.appendChild(code);
|
||||||
|
tr.appendChild(urlTd);
|
||||||
|
|
||||||
|
const addedTd = document.createElement("td");
|
||||||
|
addedTd.textContent = fmtDate(s.added_at);
|
||||||
|
tr.appendChild(addedTd);
|
||||||
|
|
||||||
|
const actTd = document.createElement("td");
|
||||||
|
actTd.className = "git-source-actions-cell";
|
||||||
|
if (s.id) {
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.type = "button";
|
||||||
|
btn.className = "git-source-remove";
|
||||||
|
btn.setAttribute("aria-label", `Remove git source: ${s.url}`);
|
||||||
|
btn.innerHTML = REMOVE_ICON + "<span>Remove</span>";
|
||||||
|
const rowError = document.createElement("span");
|
||||||
|
rowError.className = "git-source-row-error";
|
||||||
|
rowError.setAttribute("role", "alert");
|
||||||
|
rowError.hidden = true;
|
||||||
|
btn.addEventListener("click", () => removeSource(s, btn, rowError));
|
||||||
|
actTd.append(btn, rowError);
|
||||||
|
} else {
|
||||||
|
const tag = document.createElement("span");
|
||||||
|
tag.className = "git-source-env-tag";
|
||||||
|
tag.textContent = "from .env";
|
||||||
|
actTd.appendChild(tag);
|
||||||
|
}
|
||||||
|
tr.appendChild(actTd);
|
||||||
|
return tr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- remove (DELETE /api/git-sources/{id}) ----------
|
||||||
|
* Removal does not prune anything immediately — the next sync does
|
||||||
|
* (phase scope boundary), so the confirm says exactly that. Cancel →
|
||||||
|
* nothing; a failed delete → per-row role="alert" error + re-enabled
|
||||||
|
* button (never a stuck row); success → the list reloads. */
|
||||||
|
async function removeSource(s, btn, rowError) {
|
||||||
|
const ok = window.confirm(
|
||||||
|
"Remove this git source from the list? Its documents stay indexed until the next sync prunes them.",
|
||||||
|
);
|
||||||
|
if (!ok) return;
|
||||||
|
btn.disabled = true; // one delete per click
|
||||||
|
rowError.hidden = true;
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/git-sources/${encodeURIComponent(s.id)}`, { method: "DELETE" });
|
||||||
|
if (!r.ok) {
|
||||||
|
rowError.textContent = await apiDetail(r, "Could not remove the git source — try again.");
|
||||||
|
rowError.hidden = false;
|
||||||
|
btn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
announce("Git source removed.");
|
||||||
|
await loadSources(); // 204: the server confirmed — the list re-renders
|
||||||
|
} catch {
|
||||||
|
rowError.textContent = "Could not remove the git source — is the app reachable?";
|
||||||
|
rowError.hidden = false;
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- add (POST /api/git-sources) ---------- */
|
||||||
|
|
||||||
|
if (formEl && urlInput && addBtn) {
|
||||||
|
formEl.addEventListener("submit", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
// Client-side non-empty check (the input is `required` too — the
|
||||||
|
// browser's native prompt is the first line, this one the second).
|
||||||
|
const url = urlInput.value.trim();
|
||||||
|
if (!url) {
|
||||||
|
if (addError) {
|
||||||
|
addError.textContent = "Enter a git URL to add.";
|
||||||
|
addError.hidden = false;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (addError) addError.hidden = true; // a new attempt starts clean
|
||||||
|
addBtn.disabled = true; // §7.4: one POST per click
|
||||||
|
addBtn.textContent = "Adding…";
|
||||||
|
try {
|
||||||
|
const r = await fetch("/api/git-sources", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ url }),
|
||||||
|
});
|
||||||
|
if (r.ok) {
|
||||||
|
let createdId = null;
|
||||||
|
try {
|
||||||
|
createdId = (await r.json()).id ?? null;
|
||||||
|
} catch {
|
||||||
|
/* the 201 body is advisory — the reload is the truth */
|
||||||
|
}
|
||||||
|
urlInput.value = ""; // 201: the source is stored
|
||||||
|
announce("Git source added.");
|
||||||
|
await loadSources(); // the new row lands in the table
|
||||||
|
focusNewRow(createdId); // a11y: land the caret on the new row
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 409 duplicate / 422 shape / anything else: the server detail
|
||||||
|
// inline (never echoing a URL the server wouldn't), form kept —
|
||||||
|
// the input survives so the fix is one edit, not a re-type.
|
||||||
|
if (addError) {
|
||||||
|
addError.textContent = await apiDetail(r, "Could not add the git source — try again.");
|
||||||
|
addError.hidden = false;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (addError) {
|
||||||
|
addError.textContent = "Could not add the git source — is the app reachable?";
|
||||||
|
addError.hidden = false;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
addBtn.disabled = false; // never stale — success OR failure
|
||||||
|
addBtn.textContent = "Add source";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* After a successful add, focus the new row's Remove button so the
|
||||||
|
keyboard/screen-reader user lands where the new data is. The 201
|
||||||
|
body carries the row id (tr[data-id]); without one, the first row
|
||||||
|
is the fallback (the list is small and ordered). */
|
||||||
|
function focusNewRow(createdId) {
|
||||||
|
if (!tbody) return;
|
||||||
|
const row = createdId
|
||||||
|
? tbody.querySelector(`tr[data-id="${CSS.escape(createdId)}"]`)
|
||||||
|
: null;
|
||||||
|
const target = (row || tbody.querySelector("tr"))?.querySelector(".git-source-remove");
|
||||||
|
if (target) target.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- retry + boot ---------- */
|
||||||
|
|
||||||
|
if (retryBtn) retryBtn.addEventListener("click", () => loadSources());
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
// The shared header FIRST (Sign in/out + the admin-only nav links +
|
||||||
|
// the Sync button — one cached whoami), then the gate: anonymous
|
||||||
|
// visitors get the gate and NO /api/git-sources call (the Sources
|
||||||
|
// page gate pattern); the admin gets the manager.
|
||||||
|
const admin = await initSharedHeader();
|
||||||
|
if (!admin) {
|
||||||
|
if (gateEl) gateEl.hidden = false;
|
||||||
|
if (contentEl) contentEl.hidden = true; // ships hidden — stays hidden
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (gateEl) gateEl.hidden = true;
|
||||||
|
if (contentEl) contentEl.hidden = false;
|
||||||
|
await loadSources();
|
||||||
|
})();
|
||||||
@@ -6,8 +6,9 @@
|
|||||||
*
|
*
|
||||||
* • the Sign in / Sign out auth pair (phase 16, exactly one visible —
|
* • the Sign in / Sign out auth pair (phase 16, exactly one visible —
|
||||||
* decided by /api/whoami at load);
|
* decided by /api/whoami at load);
|
||||||
* • the admin-only nav links — "Sources" (#nav-sources, phase 19)
|
* • the admin-only nav links — "Sources" (#nav-sources, phase 19),
|
||||||
* and "Tuning" (#nav-tuning, phase 29) — phase 19 UX revision
|
* "Git sources" (#nav-git-sources, phase 35) and "Tuning"
|
||||||
|
* (#nav-tuning, phase 29) — phase 19 UX revision
|
||||||
* (owner permission 2026-08-23): hidden for anonymous on EVERY
|
* (owner permission 2026-08-23): hidden for anonymous on EVERY
|
||||||
* page, revealed for admin. Phase 34 task 03 (owner confirmation
|
* page, revealed for admin. Phase 34 task 03 (owner confirmation
|
||||||
* 2026-08-26): the SAME nav ships on all five pages (chat,
|
* 2026-08-26): the SAME nav ships on all five pages (chat,
|
||||||
@@ -109,6 +110,11 @@ export async function initSharedHeader() {
|
|||||||
if (signOut) signOut.hidden = !admin;
|
if (signOut) signOut.hidden = !admin;
|
||||||
const navSources = document.querySelector("#nav-sources");
|
const navSources = document.querySelector("#nav-sources");
|
||||||
if (navSources) navSources.hidden = !admin;
|
if (navSources) navSources.hidden = !admin;
|
||||||
|
// Phase 35 (owner permission 2026-08-26): the Git sources nav link —
|
||||||
|
// admin-only, the same ship-hidden / reveal-for-admin contract as
|
||||||
|
// the Sources link above.
|
||||||
|
const navGitSources = document.querySelector("#nav-git-sources");
|
||||||
|
if (navGitSources) navGitSources.hidden = !admin;
|
||||||
// Phase 29: the Global Tuning nav link (every page from phase 34
|
// Phase 29: the Global Tuning nav link (every page from phase 34
|
||||||
// task 03) — admin-only, the same ship-hidden / reveal-for-admin
|
// task 03) — admin-only, the same ship-hidden / reveal-for-admin
|
||||||
// contract as the Sources link.
|
// contract as the Sources link.
|
||||||
|
|||||||
+274
-11
@@ -1275,6 +1275,253 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
|||||||
.docs-table tbody tr:hover { background: var(--bg); }
|
.docs-table tbody tr:hover { background: var(--bg); }
|
||||||
.docs-table tbody tr:last-child td { border-bottom: 0; }
|
.docs-table tbody tr:last-child td { border-bottom: 0; }
|
||||||
|
|
||||||
|
/* ---------- Git sources page (phase 35) ----------
|
||||||
|
/git-sources.html: the admin-only manager for the stored git source
|
||||||
|
list (add / remove, git-sources table). Same full-width table
|
||||||
|
language as the Sources page (no skinny single-column list), the
|
||||||
|
phase-16 gate reused verbatim (.sources-gate classes), and the
|
||||||
|
phase-27 form-card language for the add form. Every pair reuses the
|
||||||
|
Phase-08 AA palette: dark ink on brand 5.2:1 (never white on
|
||||||
|
brand, 3.7:1, fails), brand-ink/brand-soft 6.9:1, err 9.1:1,
|
||||||
|
ink-soft >=6.9:1. Touch targets >=44px; :focus-visible via the
|
||||||
|
global 3px outline rule. No filter: blur, no CDN, system fonts. */
|
||||||
|
.git-sources-shell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add form — the tuning form's surface as a single row: visible label
|
||||||
|
+ mono URL input (the credentials case is real, so the input is
|
||||||
|
mono) + the brand "Add source" button; wraps to a column at narrow
|
||||||
|
widths (the <=640px block below). */
|
||||||
|
#git-source-form {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 0.9rem 1rem 1rem;
|
||||||
|
}
|
||||||
|
#git-source-form:focus-within { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-soft), var(--shadow); }
|
||||||
|
#git-source-form > label { color: var(--ink); font-weight: 600; white-space: nowrap; }
|
||||||
|
#git-source-url {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 14rem;
|
||||||
|
min-height: 44px;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 0.45rem 0.7rem;
|
||||||
|
}
|
||||||
|
#git-source-url::placeholder { color: var(--ink-soft); }
|
||||||
|
#git-source-url:focus-visible { outline-offset: 0; border-color: var(--brand); }
|
||||||
|
#git-source-add {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0.4rem 1.2rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--brand);
|
||||||
|
color: var(--bg); /* dark ink on brand: 5.2:1 */
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
#git-source-add:hover:not(:disabled) { background: #7d88f5; }
|
||||||
|
#git-source-add:disabled { opacity: 0.6; cursor: wait; }
|
||||||
|
|
||||||
|
/* The add form's inline error (role=alert): the err pair (9.1:1);
|
||||||
|
flex-basis 100% drops it onto its own row under the input. */
|
||||||
|
.git-source-error {
|
||||||
|
flex-basis: 100%;
|
||||||
|
margin: 0;
|
||||||
|
background: var(--err-bg);
|
||||||
|
color: var(--err-ink);
|
||||||
|
border: 1px solid var(--err-line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 0.45rem 0.8rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Load failure (role=alert) + the retry action: a failed
|
||||||
|
GET /api/git-sources must never leave a stuck page. The retry
|
||||||
|
button keeps the err pair on the err surface (9.1:1). */
|
||||||
|
.git-source-load-error {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
background: var(--err-bg);
|
||||||
|
color: var(--err-ink);
|
||||||
|
border: 1px solid var(--err-line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 0.7rem 0.9rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.git-source-load-error > span { flex: 1; min-width: 12rem; }
|
||||||
|
#git-sources-retry {
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0.4rem 1rem;
|
||||||
|
border: 1px solid var(--err-line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--err-ink);
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
#git-sources-retry:hover { background: rgb(239 68 68 / 0.12); }
|
||||||
|
|
||||||
|
/* Env-fallback note (from_env: true — the table is empty and the list
|
||||||
|
is BOR_GIT_SOURCES): the info chip in the theme palette —
|
||||||
|
brand-soft surface, brand-ink text (6.9:1). */
|
||||||
|
.git-source-env-note {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--brand-soft);
|
||||||
|
color: var(--brand-ink);
|
||||||
|
border: 1px solid var(--brand-soft);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 0.6rem 0.9rem;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
.git-source-env-note code {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.85em;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--brand-ink); /* 8.7:1 on surface */
|
||||||
|
padding: 0.1em 0.35em;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hint box (role=note): the page-sub styling family — ink-soft on
|
||||||
|
surface (6.9:1), a dashed border marks it as guidance, not state.
|
||||||
|
It names the Sync button: the action that clones the listed repos
|
||||||
|
and prunes the removed ones (the phase scope boundary). */
|
||||||
|
.git-source-hint {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px dashed var(--line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 0.6rem 0.9rem;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The list: the Sources page's table pattern — full width in the
|
||||||
|
72rem frame, surface card, horizontally scrollable wrapper (the
|
||||||
|
URL column never wraps or ellipsizes: long URLs, credentials
|
||||||
|
included, scroll the wrapper instead of truncating). */
|
||||||
|
#git-sources-table-wrap {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.git-sources-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
min-width: 560px;
|
||||||
|
font-size: 0.93rem;
|
||||||
|
}
|
||||||
|
.git-sources-table th, .git-sources-table td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.7rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.git-sources-table th {
|
||||||
|
background: var(--brand-soft);
|
||||||
|
color: var(--brand-ink);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
/* URL cell: mono at the Sources-table size; the <code> is plain
|
||||||
|
(no chip background — the cell IS the mono readout). */
|
||||||
|
.git-sources-table td.git-source-url-cell { font-family: var(--mono); font-size: 0.82rem; }
|
||||||
|
.git-sources-table td.git-source-url-cell code { font-family: inherit; }
|
||||||
|
.git-sources-table tbody tr:hover { background: var(--bg); }
|
||||||
|
.git-sources-table tbody tr:last-child td { border-bottom: 0; }
|
||||||
|
|
||||||
|
/* Per-row Remove: the tuning row-action language (icon + label,
|
||||||
|
>=44px) with the Delete hover pair (err 9.1:1). */
|
||||||
|
.git-source-remove {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
min-height: 44px;
|
||||||
|
min-width: 44px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 0.35rem 0.7rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.git-source-remove svg { width: 14px; height: 14px; display: block; }
|
||||||
|
.git-source-remove:hover:not(:disabled) { background: var(--err-bg); color: var(--err-ink); border-color: var(--err-line); }
|
||||||
|
.git-source-remove:disabled { opacity: 0.5; cursor: wait; }
|
||||||
|
|
||||||
|
/* Per-row delete failure (role=alert): the err pair, inline after the
|
||||||
|
(re-enabled) button. */
|
||||||
|
.git-source-row-error {
|
||||||
|
margin-left: 0.6rem;
|
||||||
|
background: var(--err-bg);
|
||||||
|
color: var(--err-ink);
|
||||||
|
border: 1px solid var(--err-line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Env-fallback rows carry no Remove (nothing is stored to remove) —
|
||||||
|
the tag says where the row comes from (brand pair, 6.9:1). */
|
||||||
|
.git-source-env-tag {
|
||||||
|
color: var(--brand-ink);
|
||||||
|
background: var(--brand-soft);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty state: the tuning page's quiet centered line at full table
|
||||||
|
width — no stored rows AND no env fallback to show. */
|
||||||
|
.git-sources-empty {
|
||||||
|
margin: 0;
|
||||||
|
padding: 1.4rem 1rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-style: italic;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Document viewer (phase 10; two-row header since phase 34) ---------- */
|
/* ---------- Document viewer (phase 10; two-row header since phase 34) ---------- */
|
||||||
/* Phase 34 (owner confirmation 2026-08-26): the viewer header is TWO
|
/* Phase 34 (owner confirmation 2026-08-26): the viewer header is TWO
|
||||||
rows in one sticky <header> — row 1 reuses the standard .app-header /
|
rows in one sticky <header> — row 1 reuses the standard .app-header /
|
||||||
@@ -1660,10 +1907,14 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
|||||||
— and let the brand wordmark (base ellipsis) absorb any remainder.
|
— and let the brand wordmark (base ellipsis) absorb any remainder.
|
||||||
The ≤640 block below stays tighter and wins at phone widths. */
|
The ≤640 block below stays tighter and wins at phone widths. */
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.header-inner { gap: 0.65rem; }
|
.header-inner { gap: 0.6rem; }
|
||||||
.nav-link { padding: 0.4rem 0.6rem; font-size: 0.9rem; }
|
/* Phase 35: the admin-only "Git sources" link joins the nav — a
|
||||||
.app-nav { gap: 0.2rem; }
|
fourth text pill on the admin bar — so the tablet squeeze tightens
|
||||||
.new-chat-btn, .auth-link, .sync-btn, .steering-toggle { padding: 0.45rem 0.6rem; }
|
once more to hold 768px in the admin state (brand already the
|
||||||
|
designated clip target, pills squeeze next). */
|
||||||
|
.nav-link { padding: 0.4rem 0.5rem; font-size: 0.85rem; }
|
||||||
|
.app-nav { gap: 0.15rem; }
|
||||||
|
.new-chat-btn, .auth-link, .sync-btn, .steering-toggle { padding: 0.45rem 0.5rem; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Responsive (mobile-first adjustments) ---------- */
|
/* ---------- Responsive (mobile-first adjustments) ---------- */
|
||||||
@@ -1687,7 +1938,12 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
|||||||
18px brand mark) to hold 375px with the brand mark intact and
|
18px brand mark) to hold 375px with the brand mark intact and
|
||||||
360px with the brand clipped clean (overflow:hidden — the mark
|
360px with the brand clipped clean (overflow:hidden — the mark
|
||||||
never overlaps the nav). */
|
never overlaps the nav). */
|
||||||
.header-inner { gap: 0.3rem; }
|
/* Phase 35: the admin-only "Git sources" link joins the nav — a
|
||||||
|
fourth text pill on the admin bar — so the mobile squeeze tightens
|
||||||
|
once more (0.25rem inner gap, 0.72rem nav pills, 0.3rem pill
|
||||||
|
padding, 0.05rem nav gap) to hold 375px — and 360px, the brand
|
||||||
|
fully clipped — in the admin state without horizontal overflow. */
|
||||||
|
.header-inner { gap: 0.25rem; }
|
||||||
.brand { min-width: 0; overflow: hidden; }
|
.brand { min-width: 0; overflow: hidden; }
|
||||||
.brand-mark { width: 18px; height: 18px; }
|
.brand-mark { width: 18px; height: 18px; }
|
||||||
.brand-text {
|
.brand-text {
|
||||||
@@ -1698,20 +1954,20 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.nav-link { padding: 0.35rem 0.3rem; font-size: 0.78rem; }
|
.nav-link { padding: 0.3rem 0.25rem; font-size: 0.72rem; }
|
||||||
.app-nav { gap: 0.1rem; }
|
.app-nav { gap: 0.05rem; }
|
||||||
.new-chat-btn { padding: 0.4rem 0.35rem; }
|
.new-chat-btn { padding: 0.4rem 0.3rem; }
|
||||||
.new-chat-label { display: none; }
|
.new-chat-label { display: none; }
|
||||||
.new-chat-btn svg { display: block; }
|
.new-chat-btn svg { display: block; }
|
||||||
/* Phase 16: the auth pill goes icon-only like New chat — brand text
|
/* Phase 16: the auth pill goes icon-only like New chat — brand text
|
||||||
ellipsizes as the designated squeeze target, no bar overflow. */
|
ellipsizes as the designated squeeze target, no bar overflow. */
|
||||||
.auth-link { padding: 0.4rem 0.35rem; }
|
.auth-link { padding: 0.4rem 0.3rem; }
|
||||||
.auth-label { display: none; }
|
.auth-label { display: none; }
|
||||||
.auth-link svg { display: block; }
|
.auth-link svg { display: block; }
|
||||||
/* Phase 32: the sync pill goes icon-only like the other pills (the
|
/* Phase 32: the sync pill goes icon-only like the other pills (the
|
||||||
aria-label keeps the accessible name); the spinning icon is the
|
aria-label keeps the accessible name); the spinning icon is the
|
||||||
visible running state on a touch screen. */
|
visible running state on a touch screen. */
|
||||||
.sync-btn { padding: 0.4rem 0.35rem; }
|
.sync-btn { padding: 0.4rem 0.3rem; }
|
||||||
.sync-label { display: none; }
|
.sync-label { display: none; }
|
||||||
/* The last-result counts stay ANNOUNCED (aria-live is untouched) but
|
/* The last-result counts stay ANNOUNCED (aria-live is untouched) but
|
||||||
go visually hidden — the 58px bar has no room for the text; the
|
go visually hidden — the 58px bar has no room for the text; the
|
||||||
@@ -1725,7 +1981,7 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
.steering-toggle { padding: 0.4rem 0.35rem; }
|
.steering-toggle { padding: 0.4rem 0.3rem; }
|
||||||
/* Visually hidden, NOT display:none — the accessible name keeps the
|
/* Visually hidden, NOT display:none — the accessible name keeps the
|
||||||
word "Tuning" next to the count badge. */
|
word "Tuning" next to the count badge. */
|
||||||
.steering-label {
|
.steering-label {
|
||||||
@@ -1767,6 +2023,13 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
|||||||
.doc-modal-meta { padding-inline: 0.9rem; }
|
.doc-modal-meta { padding-inline: 0.9rem; }
|
||||||
.doc-modal-content { padding: 0.75rem 0.9rem 1.25rem; }
|
.doc-modal-content { padding: 0.75rem 0.9rem 1.25rem; }
|
||||||
.composer { padding: 0.5rem; }
|
.composer { padding: 0.5rem; }
|
||||||
|
/* Phase 35: the git sources add form stacks like the other cards —
|
||||||
|
label, full-width mono input, full-width button; the table
|
||||||
|
wrapper's horizontal scroll already covers long URLs. */
|
||||||
|
#git-source-form { flex-direction: column; align-items: stretch; }
|
||||||
|
#git-source-form > label { white-space: normal; }
|
||||||
|
#git-source-url { min-width: 0; }
|
||||||
|
#git-source-add { width: 100%; }
|
||||||
.footer-inner { flex-direction: column; gap: 0.2rem; text-align: center; }
|
.footer-inner { flex-direction: column; gap: 0.2rem; text-align: center; }
|
||||||
main { padding-bottom: env(safe-area-inset-bottom, 0); }
|
main { padding-bottom: env(safe-area-inset-bottom, 0); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,11 @@
|
|||||||
reveals it once whoami says admin. The soft-gated page
|
reveals it once whoami says admin. The soft-gated page
|
||||||
itself is unchanged. -->
|
itself is unchanged. -->
|
||||||
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>Sources</a>
|
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>Sources</a>
|
||||||
|
<!-- Phase 35 (owner permission 2026-08-26): the Git sources
|
||||||
|
link is admin-only — hidden by default, header.js
|
||||||
|
reveals it once whoami says admin, exactly like the
|
||||||
|
Sources link above. -->
|
||||||
|
<a href="/git-sources.html" class="nav-link" id="nav-git-sources" hidden>Git sources</a>
|
||||||
<!-- Phase 29 (now every page — phase 34, owner confirmation
|
<!-- Phase 29 (now every page — phase 34, owner confirmation
|
||||||
2026-08-26): the Global Tuning link is admin-only (owner
|
2026-08-26): the Global Tuning link is admin-only (owner
|
||||||
permission 2026-08-25) — hidden by default, header.js
|
permission 2026-08-25) — hidden by default, header.js
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<meta name="description" content="Add and remove the git repositories Brain of Reese syncs and indexes (admin-only).">
|
||||||
|
<title>Git sources · Brain of Reese</title>
|
||||||
|
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2064%2064%22%3E%3Cpath%20d=%22M32%204%2055%2018v28L32%2060%209%2046V18Z%22%20fill=%22%23121a2e%22%20stroke=%22%236d78f2%22%20stroke-width=%224%22%20stroke-linejoin=%22round%22/%3E%3Ccircle%20cx=%2232%22%20cy=%2232%22%20r=%226.5%22%20fill=%22%236d78f2%22/%3E%3Cpath%20d=%22M32%2025.5V16M32%2048v-9.5M25.5%2032H16M48%2032h-9.5%22%20stroke=%22%2322d3ee%22%20stroke-width=%223%22%20stroke-linecap=%22round%22/%3E%3C/svg%3E">
|
||||||
|
<link rel="stylesheet" href="/assets/styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<a class="skip-link" href="#main">Skip to content</a>
|
||||||
|
|
||||||
|
<!-- Phase 35: the SAME full header block every other page ships
|
||||||
|
(phase 34, owner confirmation 2026-08-26) — one shared owner of
|
||||||
|
the controls (assets/header.js via git-sources.js's relative
|
||||||
|
import). The admin-only "Git sources" nav link (#nav-git-sources)
|
||||||
|
joins this nav in phase 35 task 05, so it is NOT in this file
|
||||||
|
yet — the page lands without it, exactly like the other pages
|
||||||
|
land without the links task 05 adds to them. -->
|
||||||
|
<header class="app-header">
|
||||||
|
<div class="container header-inner">
|
||||||
|
<span class="brand">
|
||||||
|
<svg class="brand-mark" aria-hidden="true" viewBox="0 0 64 64"><path d="M32 4 55 18v28L32 60 9 46V18Z" fill="#121a2e" stroke="#6d78f2" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#6d78f2"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#22d3ee" stroke-width="3" stroke-linecap="round"/></svg>
|
||||||
|
<span class="brand-text">Brain of <strong>Reese</strong></span>
|
||||||
|
</span>
|
||||||
|
<nav class="app-nav" aria-label="Primary">
|
||||||
|
<a href="/" class="nav-link">Chat</a>
|
||||||
|
<!-- Phase 19 (now every page — phase 34, owner confirmation
|
||||||
|
2026-08-26): the Sources link is admin-only (owner
|
||||||
|
permission 2026-08-23) — hidden by default, header.js
|
||||||
|
reveals it once whoami says admin. -->
|
||||||
|
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>Sources</a>
|
||||||
|
<!-- Phase 35 (owner permission 2026-08-26): the Git sources
|
||||||
|
link is admin-only — hidden by default, header.js
|
||||||
|
reveals it once whoami says admin, exactly like the
|
||||||
|
Sources link above; this page IS the current one, so the
|
||||||
|
link carries is-active + aria-current like Tuning on
|
||||||
|
tuning.html. -->
|
||||||
|
<a href="/git-sources.html" class="nav-link is-active" aria-current="page" id="nav-git-sources" hidden>Git sources</a>
|
||||||
|
<!-- Phase 29 (now every page — phase 34, owner confirmation
|
||||||
|
2026-08-26): the Global Tuning link is admin-only (owner
|
||||||
|
permission 2026-08-25) — hidden by default, header.js
|
||||||
|
reveals it once whoami says admin. -->
|
||||||
|
<a href="/tuning.html" class="nav-link" id="nav-tuning" hidden>Tuning</a>
|
||||||
|
</nav>
|
||||||
|
<!-- Phase 15 (now every page — phase 34, owner confirmation
|
||||||
|
2026-08-26): open the tuning-notes panel (stored in
|
||||||
|
Postgres, read into every system prompt). The behavior is
|
||||||
|
owned by the shared header module (assets/header.js); the
|
||||||
|
#steering-panel section ships in every page's <main>. -->
|
||||||
|
<button type="button" class="steering-toggle" id="steering-toggle"
|
||||||
|
aria-expanded="false" aria-controls="steering-panel">
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M4 7h10M18 7h2M4 17h4M12 17h8"/><circle cx="15.5" cy="7" r="2.2"/><circle cx="9.5" cy="17" r="2.2"/></svg>
|
||||||
|
<span class="steering-label">Tuning</span>
|
||||||
|
<span class="steering-count" id="steering-count">0</span>
|
||||||
|
</button>
|
||||||
|
<!-- Phase 32 (now every page — phase 34, owner confirmation
|
||||||
|
2026-08-26): the admin-only "Sync sources" button — SHIPS
|
||||||
|
hidden (anonymous-safe), header.js reveals it for the admin
|
||||||
|
on the SAME cached whoami. The §7.4 "never stale" lifecycle
|
||||||
|
is module-owned (assets/header.js). This page's hint box
|
||||||
|
points at it: it is the action that clones the listed repos
|
||||||
|
and prunes the removed ones. -->
|
||||||
|
<button type="button" class="sync-btn" id="sync-btn" hidden aria-label="Sync sources">
|
||||||
|
<svg class="sync-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/></svg>
|
||||||
|
<span class="sync-label" id="sync-label">Sync sources</span>
|
||||||
|
</button>
|
||||||
|
<!-- Phase 14 (now every page — phase 34, owner confirmation
|
||||||
|
2026-08-26; module-owned since phase 34 task 02): on a
|
||||||
|
non-chat page "New chat" means "go to the chat, fresh" (the
|
||||||
|
module clears the key + navigates). -->
|
||||||
|
<button type="button" class="new-chat-btn" id="new-chat-btn" aria-label="New chat">
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>
|
||||||
|
<span class="new-chat-label">New chat</span>
|
||||||
|
</button>
|
||||||
|
<!-- Phase 16: single-admin auth — exactly one of Sign in / Sign
|
||||||
|
out is visible; /api/whoami decides at load (the shared
|
||||||
|
header module). Icon-only below 640px (aria-labels keep the
|
||||||
|
accessible names). -->
|
||||||
|
<a href="/login.html?next=/git-sources.html" class="auth-link" id="sign-in-link" hidden>
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4h8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-8"/><path d="M4 12h11"/><path d="m12 9 3 3-3 3"/></svg>
|
||||||
|
<span class="auth-label">Sign in</span>
|
||||||
|
</a>
|
||||||
|
<button type="button" class="auth-link" id="sign-out-btn" aria-label="Sign out" hidden>
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M14 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M9 12h11"/><path d="m17 9 3 3-3 3"/></svg>
|
||||||
|
<span class="auth-label">Sign out</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="main" class="app-main" tabindex="-1">
|
||||||
|
<!-- Phase 15 (now every page — phase 34, owner confirmation
|
||||||
|
2026-08-26): the tuning-notes panel — first child of <main>
|
||||||
|
on the non-chat pages, rendered + driven by assets/header.js
|
||||||
|
(shared), not the page script. -->
|
||||||
|
<section class="steering-panel" id="steering-panel" role="region"
|
||||||
|
aria-label="Tuning notes" hidden>
|
||||||
|
<div class="steering-panel-head">
|
||||||
|
<h2 class="steering-panel-title">Tuning notes</h2>
|
||||||
|
<p class="steering-panel-sub">Every note below steers all future answers.</p>
|
||||||
|
</div>
|
||||||
|
<ul class="steering-list" id="steering-list"></ul>
|
||||||
|
<p class="steering-empty" id="steering-empty">No tuning notes yet — press “Tune” under any answer to add one.</p>
|
||||||
|
</section>
|
||||||
|
<p class="visually-hidden" id="steering-announcer" role="status" aria-live="polite" aria-atomic="true"></p>
|
||||||
|
<div class="container git-sources-shell">
|
||||||
|
<!-- Phase 35: anonymous sign-in gate — the EXACT #sources-gate
|
||||||
|
pattern (phase 16) and the same .sources-gate visual
|
||||||
|
language: the page is the same shape as Sources. Visible
|
||||||
|
for anonymous, hidden for the admin (git-sources.js). The
|
||||||
|
catalog of git sources is what the login locks — chat stays
|
||||||
|
open to everyone (the soft rule). -->
|
||||||
|
<section class="sources-gate" id="git-sources-gate" aria-labelledby="git-sources-gate-title" hidden>
|
||||||
|
<div class="sources-gate-glyph" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/><circle cx="12" cy="14.5" r="1.4" fill="currentColor" stroke="none"/><path d="M12 16v2"/></svg>
|
||||||
|
</div>
|
||||||
|
<h2 id="git-sources-gate-title">Sign in to manage the git sources</h2>
|
||||||
|
<p class="sources-gate-sub">
|
||||||
|
The list of repositories the <strong>Sync sources</strong> button
|
||||||
|
clones and indexes is admin-only. Chat — and any document an
|
||||||
|
answer cites — stays open to everyone.
|
||||||
|
</p>
|
||||||
|
<a class="sources-gate-link" href="/login.html?next=/git-sources.html">Sign in</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Phase 35: the manager — SHIPS hidden (anonymous-safe; the
|
||||||
|
gate is what anonymous visitors see). git-sources.js
|
||||||
|
reveals it once the cached whoami says admin, then loads
|
||||||
|
the list. Full-width table on the 72rem frame — the
|
||||||
|
Sources-page pattern, no skinny single-column list. -->
|
||||||
|
<div id="git-sources-content" hidden>
|
||||||
|
<div class="page-head">
|
||||||
|
<h1>Git sources</h1>
|
||||||
|
<p class="page-sub">
|
||||||
|
The repositories the Sync button clones and indexes. Add or
|
||||||
|
remove them here — no <code>.env</code>, no restart.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Load failure (role=alert) with a retry — a GET /api/git-sources
|
||||||
|
non-2xx or network failure must never leave a stuck page.
|
||||||
|
git-sources.js fills #git-sources-load-error-text. -->
|
||||||
|
<div class="git-source-load-error" id="git-sources-load-error" role="alert" hidden>
|
||||||
|
<span id="git-sources-load-error-text"></span>
|
||||||
|
<button type="button" id="git-sources-retry">Try again</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Env-fallback note (phase locked decision): while the
|
||||||
|
git_sources table is EMPTY the list above comes from
|
||||||
|
BOR_GIT_SOURCES in .env (from_env: true) — the note says
|
||||||
|
so, and that adding or removing here switches management
|
||||||
|
to the database. Hidden by default; git-sources.js shows
|
||||||
|
it off the API's from_env flag. -->
|
||||||
|
<p class="git-source-env-note" id="git-sources-env-note" role="note" hidden>
|
||||||
|
These sources currently come from <code>BOR_GIT_SOURCES</code> in
|
||||||
|
<code>.env</code> — adding or removing one here switches management
|
||||||
|
to the database.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Add form: visible label + mono URL input + brand button
|
||||||
|
(dark ink on brand 5.2:1). §7.4 never-stale: the button
|
||||||
|
disables + relabels "Adding…" while the POST is in flight
|
||||||
|
and re-enables on success AND failure (the input is kept
|
||||||
|
on failure, same as the tuning forms). -->
|
||||||
|
<form id="git-source-form">
|
||||||
|
<label for="git-source-url">Add a git source</label>
|
||||||
|
<input
|
||||||
|
id="git-source-url"
|
||||||
|
name="url"
|
||||||
|
type="text"
|
||||||
|
maxlength="500"
|
||||||
|
autocomplete="off"
|
||||||
|
placeholder="https://github.com/you/homelab.git"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<button type="submit" id="git-source-add">Add source</button>
|
||||||
|
<p class="git-source-error" id="git-source-error" role="alert" hidden></p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="table-wrap" id="git-sources-table-wrap" role="region" aria-label="Git sources" tabindex="0">
|
||||||
|
<table class="git-sources-table" id="git-sources-table">
|
||||||
|
<caption class="visually-hidden">Git repositories the Sync button clones and indexes</caption>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">URL</th>
|
||||||
|
<th scope="col">Added</th>
|
||||||
|
<th scope="col">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="git-sources-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state — no stored rows AND no env fallback. With
|
||||||
|
from_env, the env note above already explains where the
|
||||||
|
active list comes from. -->
|
||||||
|
<p class="git-sources-empty" id="git-sources-empty" hidden>No git sources stored yet.</p>
|
||||||
|
|
||||||
|
<!-- Scope boundary (phase locked decision): adding/removing a
|
||||||
|
repo does NOT clone or prune — the Sync button performs
|
||||||
|
that. The hint says so. -->
|
||||||
|
<p class="git-source-hint" id="git-sources-hint" role="note">
|
||||||
|
Use the <strong>Sync sources</strong> button in the header (or on
|
||||||
|
the Sources page) to clone the repos and refresh the index —
|
||||||
|
removing a repository prunes its documents from the index on the
|
||||||
|
next sync.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<!-- Polite live region: the screen-reader confirmation for list
|
||||||
|
loads, adds, and removals (git-sources.js owns the text). -->
|
||||||
|
<p class="visually-hidden" id="git-sources-announcer" role="status" aria-live="polite"></p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="app-footer">
|
||||||
|
<div class="container footer-inner">
|
||||||
|
<span>Powered by Reese's self-hosted models</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<!-- Phase 35: the page module loads the shared header through its
|
||||||
|
own `import "./header.js"` — a hoisted import evaluated before
|
||||||
|
this body runs (the single-evaluation design: no direct
|
||||||
|
header.js <script> tag; esbuild inlines it into the page
|
||||||
|
bundle in the image build). -->
|
||||||
|
<script type="module" src="/assets/git-sources.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -25,6 +25,11 @@
|
|||||||
reveals it once whoami says admin. The soft-gated page
|
reveals it once whoami says admin. The soft-gated page
|
||||||
itself is unchanged. -->
|
itself is unchanged. -->
|
||||||
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>Sources</a>
|
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>Sources</a>
|
||||||
|
<!-- Phase 35 (owner permission 2026-08-26): the Git sources
|
||||||
|
link is admin-only — hidden by default, header.js
|
||||||
|
reveals it once whoami says admin, exactly like the
|
||||||
|
Sources link above. -->
|
||||||
|
<a href="/git-sources.html" class="nav-link" id="nav-git-sources" hidden>Git sources</a>
|
||||||
<!-- Phase 29 (now every page — phase 34, owner confirmation
|
<!-- Phase 29 (now every page — phase 34, owner confirmation
|
||||||
2026-08-26): the Global Tuning link is admin-only (owner
|
2026-08-26): the Global Tuning link is admin-only (owner
|
||||||
permission 2026-08-25) — hidden by default, header.js
|
permission 2026-08-25) — hidden by default, header.js
|
||||||
|
|||||||
@@ -26,6 +26,13 @@
|
|||||||
reveals it once whoami says admin. The soft-gated page
|
reveals it once whoami says admin. The soft-gated page
|
||||||
itself is unchanged. -->
|
itself is unchanged. -->
|
||||||
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>Sources</a>
|
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>Sources</a>
|
||||||
|
<!-- Phase 35 (owner permission 2026-08-26): the Git sources
|
||||||
|
link is admin-only — hidden by default, header.js
|
||||||
|
reveals it once whoami says admin, exactly like the
|
||||||
|
Sources link above. The phase-34 identical-header contract
|
||||||
|
requires it here too: every page's nav carries the same
|
||||||
|
four links (Chat, Sources, Git sources, Tuning). -->
|
||||||
|
<a href="/git-sources.html" class="nav-link" id="nav-git-sources" hidden>Git sources</a>
|
||||||
<!-- Phase 29 (now every page — phase 34, owner confirmation
|
<!-- Phase 29 (now every page — phase 34, owner confirmation
|
||||||
2026-08-26): the Global Tuning link is admin-only (owner
|
2026-08-26): the Global Tuning link is admin-only (owner
|
||||||
permission 2026-08-25) — hidden by default, header.js
|
permission 2026-08-25) — hidden by default, header.js
|
||||||
|
|||||||
@@ -25,6 +25,11 @@
|
|||||||
reveals it once whoami says admin. The soft-gated page
|
reveals it once whoami says admin. The soft-gated page
|
||||||
itself is unchanged. -->
|
itself is unchanged. -->
|
||||||
<a href="/sources.html" class="nav-link is-active" aria-current="page" id="nav-sources" hidden>Sources</a>
|
<a href="/sources.html" class="nav-link is-active" aria-current="page" id="nav-sources" hidden>Sources</a>
|
||||||
|
<!-- Phase 35 (owner permission 2026-08-26): the Git sources
|
||||||
|
link is admin-only — hidden by default, header.js
|
||||||
|
reveals it once whoami says admin, exactly like the
|
||||||
|
Sources link above. -->
|
||||||
|
<a href="/git-sources.html" class="nav-link" id="nav-git-sources" hidden>Git sources</a>
|
||||||
<!-- Phase 29 (now every page — phase 34, owner confirmation
|
<!-- Phase 29 (now every page — phase 34, owner confirmation
|
||||||
2026-08-26): the Global Tuning link is admin-only (owner
|
2026-08-26): the Global Tuning link is admin-only (owner
|
||||||
permission 2026-08-25) — hidden by default, header.js
|
permission 2026-08-25) — hidden by default, header.js
|
||||||
|
|||||||
@@ -25,6 +25,11 @@
|
|||||||
reveals it once whoami says admin. The soft-gated page
|
reveals it once whoami says admin. The soft-gated page
|
||||||
itself is unchanged. -->
|
itself is unchanged. -->
|
||||||
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>Sources</a>
|
<a href="/sources.html" class="nav-link" id="nav-sources" hidden>Sources</a>
|
||||||
|
<!-- Phase 35 (owner permission 2026-08-26): the Git sources
|
||||||
|
link is admin-only — hidden by default, header.js
|
||||||
|
reveals it once whoami says admin, exactly like the
|
||||||
|
Sources link above. -->
|
||||||
|
<a href="/git-sources.html" class="nav-link" id="nav-git-sources" hidden>Git sources</a>
|
||||||
<!-- Phase 29 (now every page — phase 34, owner confirmation
|
<!-- Phase 29 (now every page — phase 34, owner confirmation
|
||||||
2026-08-26): the Global Tuning link is admin-only (owner
|
2026-08-26): the Global Tuning link is admin-only (owner
|
||||||
permission 2026-08-25) — hidden by default, header.js
|
permission 2026-08-25) — hidden by default, header.js
|
||||||
|
|||||||
+24
-11
@@ -7,15 +7,18 @@ Examples::
|
|||||||
uv run python -m scripts.import_docs --prune # also drop deleted/out-of-scope files
|
uv run python -m scripts.import_docs --prune # also drop deleted/out-of-scope files
|
||||||
uv run python -m scripts.import_docs --limit 5 # debug: first 5 files only
|
uv run python -m scripts.import_docs --limit 5 # debug: first 5 files only
|
||||||
|
|
||||||
Source resolution (phase 28), in precedence order:
|
Source resolution (phase 28, extended in phase 35), in precedence order:
|
||||||
|
|
||||||
1. ``--source PATH`` — explicit manual directories (repeatable) always win;
|
1. ``--source PATH`` — explicit manual directories (repeatable) always win;
|
||||||
``BOR_GIT_SOURCES`` is ignored when this flag is used.
|
git sources are ignored when this flag is used.
|
||||||
2. ``BOR_GIT_SOURCES`` (comma-separated git URLs) — each repo is cloned
|
2. The effective git sources — the admin-managed ``git_sources`` table
|
||||||
(first run, shallow ``--depth 1``) or fast-forwarded (``git pull
|
rows, else the ``BOR_GIT_SOURCES`` (comma-separated) fallback
|
||||||
--ff-only``) into ``BOR_SOURCES_DIR/<repo-name>/`` (default
|
(:func:`app.rag.git_sources.effective_git_sources`, the same shared
|
||||||
``~/bor-sources``) and the resulting checkouts are imported. A failing
|
resolver the in-app Sync button uses) — each repo is cloned (first
|
||||||
clone/pull aborts the whole run *before* anything is imported.
|
run, shallow ``--depth 1``) or fast-forwarded (``git pull --ff-only``)
|
||||||
|
into ``BOR_SOURCES_DIR/<repo-name>/`` (default ``~/bor-sources``) and
|
||||||
|
the resulting checkouts are imported. A failing clone/pull aborts the
|
||||||
|
whole run *before* anything is imported.
|
||||||
3. Fallback — the legacy ``DEFAULT_SOURCES`` (``~/Homelab`` +
|
3. Fallback — the legacy ``DEFAULT_SOURCES`` (``~/Homelab`` +
|
||||||
``~/Deployments``), kept for backwards compatibility.
|
``~/Deployments``), kept for backwards compatibility.
|
||||||
|
|
||||||
@@ -53,6 +56,7 @@ from app.core.debugging import configure_debugging
|
|||||||
from app.core.logging import configure_logging
|
from app.core.logging import configure_logging
|
||||||
from app.db import SessionLocal
|
from app.db import SessionLocal
|
||||||
from app.models import KbOverview
|
from app.models import KbOverview
|
||||||
|
from app.rag.git_sources import effective_git_sources
|
||||||
from app.rag.importer import ImportSummary, import_sources
|
from app.rag.importer import ImportSummary, import_sources
|
||||||
from app.rag.llm import LLMClient
|
from app.rag.llm import LLMClient
|
||||||
from app.rag.overview import regenerate_overview
|
from app.rag.overview import regenerate_overview
|
||||||
@@ -111,20 +115,29 @@ def repo_name(url: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _resolve_sources(cli_sources: list[Path] | None, settings: Settings) -> list[Path]:
|
def _resolve_sources(cli_sources: list[Path] | None, settings: Settings) -> list[Path]:
|
||||||
"""Resolve the directories to import (phase 28).
|
"""Resolve the directories to import (phase 28, extended in phase 35).
|
||||||
|
|
||||||
Precedence: ``--source`` (explicit manual paths — always wins) >
|
Precedence: ``--source`` (explicit manual paths — always wins) >
|
||||||
``BOR_GIT_SOURCES`` (each URL cloned/pulled via
|
the effective git sources — the ``git_sources`` DB rows, else the
|
||||||
|
``BOR_GIT_SOURCES`` fallback
|
||||||
|
(:func:`app.rag.git_sources.effective_git_sources`; the import needs
|
||||||
|
the database anyway, so resolution opens a short session and there
|
||||||
|
is no DB-down branch) — each URL cloned/pulled via
|
||||||
:func:`scripts.git_sync.clone_or_pull` into
|
:func:`scripts.git_sync.clone_or_pull` into
|
||||||
``BOR_SOURCES_DIR/<repo-name>/``) > the legacy ``DEFAULT_SOURCES``.
|
``BOR_SOURCES_DIR/<repo-name>/`` > the legacy ``DEFAULT_SOURCES``.
|
||||||
|
|
||||||
A :class:`GitSyncError` from a failing clone/pull propagates to
|
A :class:`GitSyncError` from a failing clone/pull propagates to
|
||||||
:func:`main`, which aborts the run before importing anything.
|
:func:`main`, which aborts the run before importing anything.
|
||||||
"""
|
"""
|
||||||
if cli_sources:
|
if cli_sources:
|
||||||
return [path.expanduser() for path in cli_sources]
|
return [path.expanduser() for path in cli_sources]
|
||||||
git_urls = settings.git_source_list
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
git_urls, origin = effective_git_sources(db)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
if git_urls:
|
if git_urls:
|
||||||
|
logger.info("git sources: %d repo(s) origin=%s", len(git_urls), origin)
|
||||||
sources_root = Path(settings.sources_dir).expanduser()
|
sources_root = Path(settings.sources_dir).expanduser()
|
||||||
return [clone_or_pull(url, sources_root / repo_name(url)) for url in git_urls]
|
return [clone_or_pull(url, sources_root / repo_name(url)) for url in git_urls]
|
||||||
return [path.expanduser() for path in DEFAULT_SOURCES]
|
return [path.expanduser() for path in DEFAULT_SOURCES]
|
||||||
|
|||||||
@@ -0,0 +1,572 @@
|
|||||||
|
"""Phase 35 story E2E (Playwright): the admin page to add / remove git
|
||||||
|
sources (``/git-sources.html``).
|
||||||
|
|
||||||
|
Story: ``.agent/user_stories/git-sources-admin.md``
|
||||||
|
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||||||
|
|
||||||
|
uv run pytest tests/e2e/test_git_sources_admin.py -v --no-cov
|
||||||
|
|
||||||
|
The story gate for the admin-managed git source list. **No git, no
|
||||||
|
network** — this suite is UI + API only (the clone/import pipeline is
|
||||||
|
mocked at the integration level, phase 35 task 03; a real sync is
|
||||||
|
deliberately never triggered here). The stored list (``git_sources``
|
||||||
|
table, migration 0006) is exercised through the real page and the real
|
||||||
|
admin-only CRUD API (phase 35 task 02).
|
||||||
|
|
||||||
|
Per-module app env (the conftest pattern, module-scoped — as in
|
||||||
|
``test_sync_button.py``): this story's app boots with a fixed
|
||||||
|
``BOR_GIT_SOURCES`` CSV (two deterministic URLs that are NEVER cloned —
|
||||||
|
nothing in this suite triggers a sync) so the empty-table env fallback
|
||||||
|
(the phase's locked decision: ``BOR_GIT_SOURCES`` only applies while the
|
||||||
|
``git_sources`` table is empty) can be asserted against real env rows.
|
||||||
|
The session app (no git sources) is never started in this isolated run,
|
||||||
|
so no port clash.
|
||||||
|
|
||||||
|
Contract under test:
|
||||||
|
|
||||||
|
* anonymous: the sign-in gate (the exact ``#sources-gate`` pattern), the
|
||||||
|
manager hidden (list + add form inert), NO ``/api/git-sources`` call,
|
||||||
|
403 on all three routes (asserted with the page context's request
|
||||||
|
client, the ``test_admin_auth.py``/``test_sync_button.py`` pattern),
|
||||||
|
and ``#nav-git-sources`` hidden on all five pages;
|
||||||
|
* admin: ``#nav-git-sources`` visible on all five pages (revealed by the
|
||||||
|
shared header on the cached whoami), clicking it from the chat lands
|
||||||
|
on ``/git-sources.html`` with the link ``is-active``; the stored rows
|
||||||
|
render as a full-width table (mono URL, added date, per-row Remove)
|
||||||
|
with the env note hidden while the DB has rows; add (201 → row, input
|
||||||
|
cleared, button re-enabled), duplicate (inline role=alert, no new
|
||||||
|
row), invalid shape (inline 422, no new row), remove (confirm → gone;
|
||||||
|
cancel → stays); with the table truncated the env rows render with
|
||||||
|
"from .env" tags and ``#git-sources-env-note`` visible;
|
||||||
|
* the page a11y / no-CDN basics (UI Structure Check, AGENTS.md rule 5):
|
||||||
|
landmarks, labeled form control, full-width table, ≥44px targets,
|
||||||
|
3px focus-visible outline, the aria-live list announcer, same-origin
|
||||||
|
assets only (rule 6).
|
||||||
|
|
||||||
|
Test → story mapping (Playwright Mapping Rule):
|
||||||
|
1. ``test_anonymous_gate_no_api_calls_and_403s``
|
||||||
|
2. ``test_admin_nav_link_on_all_five_pages_and_click_navigates``
|
||||||
|
3. ``test_admin_sees_seeded_rows_with_dates_and_no_env_note``
|
||||||
|
4. ``test_admin_add_then_remove_lifecycle``
|
||||||
|
5. ``test_admin_env_fallback_rows_and_note``
|
||||||
|
6. ``test_admin_page_a11y_and_no_cdn``
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import uuid
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from playwright.sync_api import Dialog, Page, expect
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app.db import SessionLocal
|
||||||
|
from app.models import GitSource
|
||||||
|
from e2e.auth_helpers import login
|
||||||
|
from e2e.conftest import (
|
||||||
|
ADMIN_PASSWORD,
|
||||||
|
APP_PORT,
|
||||||
|
SESSION_SECRET,
|
||||||
|
USE_REAL_LLM,
|
||||||
|
_wait_http,
|
||||||
|
)
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parents[2]
|
||||||
|
APP_URL = f"http://127.0.0.1:{APP_PORT}"
|
||||||
|
|
||||||
|
#: The five pages that ship the header (phase 34 contract) — the pages
|
||||||
|
#: task 05 gave the admin-only "Git sources" nav link.
|
||||||
|
CHAT_URL = "/"
|
||||||
|
SOURCES_URL = "/sources.html"
|
||||||
|
VIEWER_URL = "/document.html?source=docs&path=homelab%2Fkubernetes.md"
|
||||||
|
TUNING_URL = "/tuning.html"
|
||||||
|
GIT_SOURCES_URL = "/git-sources.html"
|
||||||
|
FIVE_PAGES = (
|
||||||
|
("chat", CHAT_URL),
|
||||||
|
("sources", SOURCES_URL),
|
||||||
|
("viewer", VIEWER_URL),
|
||||||
|
("tuning", TUNING_URL),
|
||||||
|
("git-sources", GIT_SOURCES_URL),
|
||||||
|
)
|
||||||
|
|
||||||
|
#: The module app's ``BOR_GIT_SOURCES`` — two deterministic URLs that
|
||||||
|
#: are NEVER cloned (no sync is triggered in this suite; the fallback
|
||||||
|
#: list is what is under test).
|
||||||
|
ENV_SOURCE_A = "https://github.com/reese/env-alpha.git"
|
||||||
|
ENV_SOURCE_B = "https://github.com/reese/env-beta.git"
|
||||||
|
ENV_SOURCES_CSV = ",".join((ENV_SOURCE_A, ENV_SOURCE_B))
|
||||||
|
|
||||||
|
#: ``is-active`` as a word-boundary regex — to_have_class() matches the
|
||||||
|
#: WHOLE class string, so the current-page marker is asserted the same
|
||||||
|
#: way test_tuning_nav_link.py does it.
|
||||||
|
IS_ACTIVE = re.compile(r"\bis-active\b")
|
||||||
|
|
||||||
|
#: Deterministic URLs for the UI-driven assertions.
|
||||||
|
SEED_URL = "https://gitlab.example.com/reese/seeded.git"
|
||||||
|
NEW_REPO_URL = "https://example.com/reese/new-repo.git"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def app_server(mock_llm: int) -> Iterator[str]:
|
||||||
|
"""The real app under test — per-module env: a fixed two-URL
|
||||||
|
``BOR_GIT_SOURCES`` CSV so the empty-table env fallback (the phase's
|
||||||
|
locked decision) is live. No ``BOR_SOURCES_DIR``, no git: nothing in
|
||||||
|
this suite clones or syncs."""
|
||||||
|
env = dict(os.environ)
|
||||||
|
env.pop("DEBUGPY", None)
|
||||||
|
env["BOR_ENVIRONMENT"] = "e2e"
|
||||||
|
env["BOR_STATIC_DIR"] = str(REPO / "frontend")
|
||||||
|
env["BOR_LLM_BASE_URL"] = (
|
||||||
|
"https://aipi.reeseapps.com/v1"
|
||||||
|
if USE_REAL_LLM
|
||||||
|
else f"http://127.0.0.1:{mock_llm}/v1"
|
||||||
|
)
|
||||||
|
# Mock-calibrated threshold (conftest pattern) — no chat turn is
|
||||||
|
# ever sent in this suite, but the app boots with the same env shape.
|
||||||
|
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
|
||||||
|
env.setdefault(
|
||||||
|
"BOR_DATABASE_URL",
|
||||||
|
"postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese",
|
||||||
|
)
|
||||||
|
# Phase 16: admin auth must be set or create_app() refuses to boot.
|
||||||
|
env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
|
||||||
|
env["BOR_SESSION_SECRET"] = SESSION_SECRET
|
||||||
|
# Phase 35: the empty-table fallback list (never cloned here).
|
||||||
|
env["BOR_GIT_SOURCES"] = ENV_SOURCES_CSV
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
[sys.executable, "-m", "uvicorn", "app.main:app",
|
||||||
|
"--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"],
|
||||||
|
cwd=REPO,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
_wait_http(f"{APP_URL}/api/health")
|
||||||
|
yield APP_URL
|
||||||
|
finally:
|
||||||
|
proc.terminate()
|
||||||
|
try:
|
||||||
|
proc.wait(timeout=10)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def app_url(app_server: str) -> str:
|
||||||
|
return app_server
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate_git_sources() -> None:
|
||||||
|
with SessionLocal() as db:
|
||||||
|
db.execute(text("TRUNCATE git_sources"))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clean_git_sources(db_ready: None) -> Iterator[None]:
|
||||||
|
"""Fresh ``git_sources`` table per test — this suite owns the table
|
||||||
|
(the E2E isolation pattern); the KB tables are irrelevant here and
|
||||||
|
are left untouched (the viewer page may show its not-found state —
|
||||||
|
no document assertion is made about it). The table is emptied BOTH
|
||||||
|
before and after every test: suites run in isolation but share one
|
||||||
|
Postgres, and a leftover row would flip another suite's app from the
|
||||||
|
``BOR_GIT_SOURCES`` env fallback to the DB list (the sync story's
|
||||||
|
``effective_git_sources`` resolver reads the DB first)."""
|
||||||
|
_truncate_git_sources()
|
||||||
|
yield
|
||||||
|
_truncate_git_sources()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_rows(urls: list[str]) -> None:
|
||||||
|
"""Store rows directly (deterministic, ordered added_at) — the task
|
||||||
|
allows seeding via the API *or* SessionLocal; direct inserts keep
|
||||||
|
the list-rendering test independent of the add/remove lifecycle
|
||||||
|
test."""
|
||||||
|
base = datetime(2026, 8, 26, 9, 0, 0, tzinfo=UTC)
|
||||||
|
with SessionLocal() as db:
|
||||||
|
for i, url in enumerate(urls):
|
||||||
|
db.add(GitSource(url=url, added_at=base + timedelta(minutes=i)))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_git_sources_page(page: Page, app_url: str) -> None:
|
||||||
|
"""Real form login landing on the git sources page (admin settled:
|
||||||
|
Sign out visible, the manager revealed by the page module)."""
|
||||||
|
login(page, app_url, next=GIT_SOURCES_URL)
|
||||||
|
expect(page).to_have_url(app_url + GIT_SOURCES_URL, timeout=30_000)
|
||||||
|
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
|
||||||
|
expect(page.locator("#git-sources-gate")).to_be_hidden()
|
||||||
|
expect(page.locator("#git-sources-content")).to_be_visible()
|
||||||
|
|
||||||
|
|
||||||
|
def _row(page: Page, url: str) -> Any:
|
||||||
|
"""The table row whose mono URL cell shows ``url``."""
|
||||||
|
return page.locator("#git-sources-tbody tr", has_text=url)
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_settled(page: Page, admin: bool) -> None:
|
||||||
|
"""The shared header's whoami toggle has landed (one of Sign in /
|
||||||
|
Sign out visible) — the same settled-state gate as
|
||||||
|
test_nav_consistency.py."""
|
||||||
|
if admin:
|
||||||
|
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
|
||||||
|
expect(page.locator("#sign-in-link")).to_be_hidden()
|
||||||
|
else:
|
||||||
|
expect(page.locator("#sign-in-link")).to_be_visible(timeout=15_000)
|
||||||
|
expect(page.locator("#sign-out-btn")).to_be_hidden()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. Anonymous: gate, inert manager, no API calls, 403s, hidden link
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_gate_no_api_calls_and_403s(
|
||||||
|
page: Page, app_url: str, db_ready: None
|
||||||
|
) -> None:
|
||||||
|
page.set_default_timeout(30_000)
|
||||||
|
|
||||||
|
# Track every /api/git-sources request the page itself makes — the
|
||||||
|
# gate must be reached WITHOUT touching the admin API (the
|
||||||
|
# test_admin_auth.py pattern for the Sources page's /api/docs).
|
||||||
|
api_calls: list[str] = []
|
||||||
|
page.on(
|
||||||
|
"request",
|
||||||
|
lambda r: api_calls.append(r.url) if "/api/git-sources" in r.url else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
page.goto(app_url + GIT_SOURCES_URL)
|
||||||
|
_assert_settled(page, admin=False)
|
||||||
|
|
||||||
|
# The sign-in gate (the #sources-gate pattern, phase 16)…
|
||||||
|
gate = page.locator("#git-sources-gate")
|
||||||
|
expect(gate).to_be_visible()
|
||||||
|
expect(gate).to_contain_text("Sign in to manage the git sources")
|
||||||
|
link = gate.locator("a[href='/login.html?next=/git-sources.html']")
|
||||||
|
expect(link).to_have_count(1)
|
||||||
|
box = link.bounding_box()
|
||||||
|
assert box is not None and box["height"] >= 44, f"gate link too small: {box}"
|
||||||
|
|
||||||
|
# …and the manager is absent/inert: list, add form, env note — all
|
||||||
|
# inside the hidden #git-sources-content.
|
||||||
|
expect(page.locator("#git-sources-content")).to_be_hidden()
|
||||||
|
expect(page.locator("#git-sources-table")).to_be_hidden()
|
||||||
|
expect(page.locator("#git-source-form")).to_be_hidden()
|
||||||
|
expect(page.locator("#git-sources-env-note")).to_be_hidden()
|
||||||
|
|
||||||
|
# The admin-only nav link is hidden on this page…
|
||||||
|
expect(page.locator("#nav-git-sources")).to_be_hidden()
|
||||||
|
# …and on the other four pages (all five ship it hidden by default).
|
||||||
|
for _name, path in FIVE_PAGES:
|
||||||
|
if path == GIT_SOURCES_URL:
|
||||||
|
continue
|
||||||
|
page.goto(app_url + path)
|
||||||
|
_assert_settled(page, admin=False)
|
||||||
|
expect(page.locator("#nav-git-sources")).to_be_hidden()
|
||||||
|
|
||||||
|
# The gate never called the admin API…
|
||||||
|
assert api_calls == [], f"anonymous page called the git sources API: {api_calls}"
|
||||||
|
# …and the API 403s anonymous callers on all three routes (the
|
||||||
|
# test_sync_button.py pattern — the page context has no cookie).
|
||||||
|
assert page.request.get(f"{app_url}/api/git-sources").status == 403
|
||||||
|
assert (
|
||||||
|
page.request.post(
|
||||||
|
f"{app_url}/api/git-sources", data={"url": NEW_REPO_URL}
|
||||||
|
).status
|
||||||
|
== 403
|
||||||
|
)
|
||||||
|
assert page.request.delete(f"{app_url}/api/git-sources/{uuid.uuid4()}").status == 403
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. Admin: the nav link on all five pages; the click lands + is-active
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_nav_link_on_all_five_pages_and_click_navigates(
|
||||||
|
page: Page, app_url: str, db_ready: None
|
||||||
|
) -> None:
|
||||||
|
page.set_default_timeout(30_000)
|
||||||
|
login(page, app_url, next=CHAT_URL)
|
||||||
|
expect(page).to_have_url(app_url + CHAT_URL, timeout=30_000)
|
||||||
|
|
||||||
|
# The link is revealed (admin) on every one of the five pages,
|
||||||
|
# pointing at the git sources page, labeled "Git sources" — and it
|
||||||
|
# is NOT the current page on the four non-git-sources pages.
|
||||||
|
for _name, path in FIVE_PAGES:
|
||||||
|
if path != CHAT_URL:
|
||||||
|
page.goto(app_url + path)
|
||||||
|
_assert_settled(page, admin=True)
|
||||||
|
link = page.locator("#nav-git-sources")
|
||||||
|
expect(link).to_be_visible(timeout=15_000)
|
||||||
|
expect(link).to_have_attribute("href", GIT_SOURCES_URL)
|
||||||
|
expect(link).to_have_text("Git sources")
|
||||||
|
if path != GIT_SOURCES_URL:
|
||||||
|
expect(link).not_to_have_class(IS_ACTIVE)
|
||||||
|
|
||||||
|
# From the chat: a real click navigates…
|
||||||
|
page.goto(app_url + CHAT_URL)
|
||||||
|
_assert_settled(page, admin=True)
|
||||||
|
page.click("#nav-git-sources")
|
||||||
|
expect(page).to_have_url(app_url + GIT_SOURCES_URL, timeout=30_000)
|
||||||
|
# …where the page's own link is the current-page marker.
|
||||||
|
link = page.locator("#nav-git-sources")
|
||||||
|
expect(link).to_be_visible(timeout=15_000)
|
||||||
|
expect(link).to_have_class(IS_ACTIVE)
|
||||||
|
expect(link).to_have_attribute("aria-current", "page")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. Admin: two seeded rows render (mono URL + added date); env note
|
||||||
|
# hidden while the DB has rows
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_sees_seeded_rows_with_dates_and_no_env_note(
|
||||||
|
page: Page, app_url: str, db_ready: None
|
||||||
|
) -> None:
|
||||||
|
page.set_default_timeout(30_000)
|
||||||
|
_seed_rows([SEED_URL, ENV_SOURCE_A]) # ordered by added_at
|
||||||
|
|
||||||
|
_admin_git_sources_page(page, app_url)
|
||||||
|
|
||||||
|
# Both rows render, oldest first…
|
||||||
|
expect(page.locator("#git-sources-tbody tr")).to_have_count(2)
|
||||||
|
first = _row(page, SEED_URL)
|
||||||
|
second = _row(page, ENV_SOURCE_A)
|
||||||
|
expect(first.locator("td.git-source-url-cell code")).to_have_text(SEED_URL)
|
||||||
|
expect(second.locator("td.git-source-url-cell code")).to_have_text(ENV_SOURCE_A)
|
||||||
|
expect(page.locator("#git-sources-tbody tr").first).to_contain_text(SEED_URL)
|
||||||
|
expect(page.locator("#git-sources-tbody tr").last).to_contain_text(ENV_SOURCE_A)
|
||||||
|
|
||||||
|
# …each with the mono URL cell, a rendered added date (not the "—"
|
||||||
|
# env-fallback placeholder), and a labeled per-row Remove button.
|
||||||
|
for row, url in ((first, SEED_URL), (second, ENV_SOURCE_A)):
|
||||||
|
added = row.locator("td").nth(1)
|
||||||
|
expect(added).not_to_be_empty()
|
||||||
|
expect(added).not_to_have_text("—")
|
||||||
|
remove = row.locator(".git-source-remove")
|
||||||
|
expect(remove).to_have_count(1)
|
||||||
|
expect(remove).to_have_attribute("aria-label", f"Remove git source: {url}")
|
||||||
|
|
||||||
|
# DB rows exist → from_env is false: the env note is hidden and the
|
||||||
|
# empty state too.
|
||||||
|
expect(page.locator("#git-sources-env-note")).to_be_hidden()
|
||||||
|
expect(page.locator("#git-sources-empty")).to_be_hidden()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 4. Admin: add → duplicate → invalid → remove (accept + cancel) — the
|
||||||
|
# full never-stale lifecycle
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_add_then_remove_lifecycle(
|
||||||
|
page: Page, app_url: str, db_ready: None
|
||||||
|
) -> None:
|
||||||
|
page.set_default_timeout(30_000)
|
||||||
|
_seed_rows([SEED_URL])
|
||||||
|
|
||||||
|
_admin_git_sources_page(page, app_url)
|
||||||
|
expect(page.locator("#git-sources-tbody tr")).to_have_count(1)
|
||||||
|
|
||||||
|
error = page.locator("#git-source-error")
|
||||||
|
add_btn = page.locator("#git-source-add")
|
||||||
|
|
||||||
|
# --- add: 201 → the row appears, the input clears, the button
|
||||||
|
# re-enables (never stale) ---------------------------------------
|
||||||
|
page.fill("#git-source-url", NEW_REPO_URL)
|
||||||
|
add_btn.click()
|
||||||
|
expect(page.locator("#git-sources-tbody tr")).to_have_count(2, timeout=30_000)
|
||||||
|
expect(_row(page, NEW_REPO_URL).locator("td.git-source-url-cell code")).to_have_text(
|
||||||
|
NEW_REPO_URL
|
||||||
|
)
|
||||||
|
expect(page.locator("#git-source-url")).to_have_value("")
|
||||||
|
expect(add_btn).to_be_enabled()
|
||||||
|
expect(add_btn).to_have_text("Add source")
|
||||||
|
# A stored row exists → the env note stays hidden.
|
||||||
|
expect(page.locator("#git-sources-env-note")).to_be_hidden()
|
||||||
|
|
||||||
|
# --- duplicate: inline role=alert, NO new row, button re-enabled,
|
||||||
|
# the (failed) input survives for one edit ------------------------
|
||||||
|
page.fill("#git-source-url", NEW_REPO_URL)
|
||||||
|
add_btn.click()
|
||||||
|
expect(error).to_be_visible(timeout=30_000)
|
||||||
|
assert error.get_attribute("role") == "alert"
|
||||||
|
expect(error).to_contain_text("already exists")
|
||||||
|
expect(page.locator("#git-sources-tbody tr")).to_have_count(2)
|
||||||
|
expect(add_btn).to_be_enabled()
|
||||||
|
expect(add_btn).to_have_text("Add source")
|
||||||
|
expect(page.locator("#git-source-url")).to_have_value(NEW_REPO_URL)
|
||||||
|
|
||||||
|
# --- invalid shape: inline 422 (the server detail, never the
|
||||||
|
# echoed input), NO new row, button re-enabled --------------------
|
||||||
|
page.fill("#git-source-url", "not a valid url")
|
||||||
|
add_btn.click()
|
||||||
|
expect(error).to_be_visible(timeout=30_000)
|
||||||
|
expect(error).to_contain_text("not a valid git URL")
|
||||||
|
expect(page.locator("#git-sources-tbody tr")).to_have_count(2)
|
||||||
|
expect(add_btn).to_be_enabled()
|
||||||
|
expect(add_btn).to_have_text("Add source")
|
||||||
|
|
||||||
|
# --- remove: accept the confirm → the row disappears --------------
|
||||||
|
dialog_action: dict[str, bool] = {"accept": True}
|
||||||
|
deletes: list[str] = []
|
||||||
|
|
||||||
|
def handle_dialog(dialog: Dialog) -> None:
|
||||||
|
if dialog_action["accept"]:
|
||||||
|
dialog.accept()
|
||||||
|
else:
|
||||||
|
dialog.dismiss()
|
||||||
|
|
||||||
|
def track_delete(r: Any) -> None:
|
||||||
|
if r.method == "DELETE" and "/api/git-sources/" in r.url:
|
||||||
|
deletes.append(r.url)
|
||||||
|
|
||||||
|
page.on("dialog", handle_dialog)
|
||||||
|
page.on("request", track_delete)
|
||||||
|
try:
|
||||||
|
_row(page, NEW_REPO_URL).locator(".git-source-remove").click()
|
||||||
|
expect(page.locator("#git-sources-tbody tr")).to_have_count(1, timeout=30_000)
|
||||||
|
expect(_row(page, NEW_REPO_URL)).to_have_count(0)
|
||||||
|
# The seed row survived — and exactly one DELETE went out.
|
||||||
|
expect(_row(page, SEED_URL)).to_have_count(1)
|
||||||
|
assert len(deletes) == 1, f"expected one DELETE, saw: {deletes}"
|
||||||
|
|
||||||
|
# --- remove: cancel the confirm → the row stays, NO DELETE ----
|
||||||
|
dialog_action["accept"] = False
|
||||||
|
_row(page, SEED_URL).locator(".git-source-remove").click()
|
||||||
|
# Wait for the dialog round-trip to settle (dismiss → the JS
|
||||||
|
# returns without fetching) so the "no second DELETE" claim is
|
||||||
|
# made on a settled page.
|
||||||
|
page.wait_for_timeout(500)
|
||||||
|
assert len(deletes) == 1, f"canceled removal still deleted: {deletes}"
|
||||||
|
expect(_row(page, SEED_URL)).to_have_count(1)
|
||||||
|
expect(page.locator("#git-sources-tbody tr")).to_have_count(1)
|
||||||
|
finally:
|
||||||
|
page.remove_listener("dialog", handle_dialog)
|
||||||
|
page.remove_listener("request", track_delete)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 5. Admin: empty table → the env rows render + the env note is visible
|
||||||
|
# (BOR_GIT_SOURCES is the empty-table fallback)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_env_fallback_rows_and_note(
|
||||||
|
page: Page, app_url: str, db_ready: None
|
||||||
|
) -> None:
|
||||||
|
page.set_default_timeout(30_000)
|
||||||
|
# The autouse fixture truncated git_sources — the table is EMPTY, so
|
||||||
|
# the module app's BOR_GIT_SOURCES CSV is the effective list.
|
||||||
|
_admin_git_sources_page(page, app_url)
|
||||||
|
|
||||||
|
# The two env rows render (CSV order), each tagged "from .env" with
|
||||||
|
# no Remove (nothing is stored to remove) and the "—" added date.
|
||||||
|
expect(page.locator("#git-sources-tbody tr")).to_have_count(2)
|
||||||
|
expect(page.locator("#git-sources-tbody tr").first).to_contain_text(ENV_SOURCE_A)
|
||||||
|
expect(page.locator("#git-sources-tbody tr").last).to_contain_text(ENV_SOURCE_B)
|
||||||
|
for row in page.locator("#git-sources-tbody tr").all():
|
||||||
|
expect(row.locator(".git-source-env-tag")).to_have_count(1)
|
||||||
|
expect(row.locator(".git-source-env-tag")).to_have_text("from .env")
|
||||||
|
expect(row.locator(".git-source-remove")).to_have_count(0)
|
||||||
|
expect(row.locator("td").nth(1)).to_have_text("—")
|
||||||
|
|
||||||
|
# The env-fallback note explains the active list's origin…
|
||||||
|
expect(page.locator("#git-sources-env-note")).to_be_visible()
|
||||||
|
# …and the API agrees: from_env true, null ids, the env URLs.
|
||||||
|
r = page.request.get(f"{app_url}/api/git-sources")
|
||||||
|
assert r.status == 200, r.text
|
||||||
|
body = r.json()
|
||||||
|
assert body["from_env"] is True
|
||||||
|
assert [s["url"] for s in body["sources"]] == [ENV_SOURCE_A, ENV_SOURCE_B]
|
||||||
|
assert all(s["id"] is None and s["added_at"] is None for s in body["sources"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 6. UI Structure Check (AGENTS.md rule 5) + no CDN (rule 6)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_page_a11y_and_no_cdn(
|
||||||
|
page: Page, app_url: str, db_ready: None
|
||||||
|
) -> None:
|
||||||
|
page.set_default_timeout(30_000)
|
||||||
|
_seed_rows([SEED_URL])
|
||||||
|
|
||||||
|
_admin_git_sources_page(page, app_url)
|
||||||
|
expect(page.locator("#git-sources-tbody tr")).to_have_count(1)
|
||||||
|
|
||||||
|
# Standard app frame: landmarks + skip link (PLAN §7.2).
|
||||||
|
expect(page.locator("header.app-header")).to_have_count(1)
|
||||||
|
expect(page.locator('nav[aria-label="Primary"]')).to_have_count(1)
|
||||||
|
expect(page.locator("main#main")).to_have_count(1)
|
||||||
|
expect(page.locator("footer.app-footer")).to_have_count(1)
|
||||||
|
expect(page.locator(".skip-link")).to_have_count(1)
|
||||||
|
|
||||||
|
# No CDN (rule 6): no https:// asset tags; every script/link ref is
|
||||||
|
# same-origin or a data: URI.
|
||||||
|
html = page.content()
|
||||||
|
assert 'src="https://' not in html and 'href="https://' not in html
|
||||||
|
refs = page.evaluate(
|
||||||
|
"""() => [...document.querySelectorAll("script[src], link[href]")]
|
||||||
|
.map((el) => el.src || el.href)"""
|
||||||
|
)
|
||||||
|
assert refs, "expected local asset references"
|
||||||
|
for ref in refs:
|
||||||
|
assert ref.startswith(app_url) or ref.startswith("data:"), (
|
||||||
|
f"non-local asset reference: {ref}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# The form input is labeled (visible <label for=…>).
|
||||||
|
url_input = page.get_by_label("Add a git source")
|
||||||
|
expect(url_input).to_have_count(1)
|
||||||
|
|
||||||
|
# Full-width table (PLAN §7.1 — no skinny list): the table fills the
|
||||||
|
# 72rem container (≥80%, the Sources-page assertion).
|
||||||
|
table = page.locator("#git-sources-table")
|
||||||
|
expect(table).to_be_visible()
|
||||||
|
table_box = table.bounding_box()
|
||||||
|
shell_box = page.locator(".git-sources-shell").bounding_box()
|
||||||
|
assert table_box is not None and shell_box is not None
|
||||||
|
assert table_box["width"] >= 0.80 * shell_box["width"], (
|
||||||
|
f"table is {table_box['width']:.0f}px in a {shell_box['width']:.0f}px container"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Touch targets ≥44px (add button + a row's Remove).
|
||||||
|
for el in (page.locator("#git-source-add"), page.locator(".git-source-remove")):
|
||||||
|
box = el.bounding_box()
|
||||||
|
assert box is not None and box["height"] >= 44, f"target too small: {box}"
|
||||||
|
|
||||||
|
# :focus-visible draws the 3px outline (the theme contract).
|
||||||
|
page.focus("#git-source-url")
|
||||||
|
outline = page.evaluate(
|
||||||
|
"() => getComputedStyle(document.querySelector('#git-source-url')).outlineWidth"
|
||||||
|
)
|
||||||
|
assert outline == "3px", f"focus-visible outline missing: {outline!r}"
|
||||||
|
|
||||||
|
# The list updates are announced (aria-live) and the error line is a
|
||||||
|
# role=alert region; the env note + hint are role=note.
|
||||||
|
announcer = page.locator("#git-sources-announcer")
|
||||||
|
assert announcer.get_attribute("role") == "status"
|
||||||
|
assert announcer.get_attribute("aria-live") == "polite"
|
||||||
|
assert page.locator("#git-source-error").get_attribute("role") == "alert"
|
||||||
|
assert page.locator("#git-sources-env-note").get_attribute("role") == "note"
|
||||||
|
assert page.locator("#git-sources-hint").get_attribute("role") == "note"
|
||||||
@@ -18,15 +18,17 @@ sources, document viewer, global tuning, login): one shared markup block
|
|||||||
|
|
||||||
Per role, the VISIBLE inventory:
|
Per role, the VISIBLE inventory:
|
||||||
|
|
||||||
* admin: brand + nav [Chat, #nav-sources, #nav-tuning] + #steering-toggle
|
* admin: brand + nav [Chat, #nav-sources, #nav-git-sources, #nav-tuning]
|
||||||
+ #sync-btn + #new-chat-btn + #sign-out-btn (with #sign-in-link
|
(four links, that order — the Git sources link joined in phase 35,
|
||||||
hidden) — on all five pages, same id+class inventory, same DOM order;
|
owner permission 2026-08-26) + #steering-toggle + #sync-btn +
|
||||||
* anonymous: brand + nav [Chat] (#nav-sources / #nav-tuning hidden —
|
#new-chat-btn + #sign-out-btn (with #sign-in-link hidden) — on all
|
||||||
locked A10 UI revision) + #new-chat-btn + #sign-in-link (with
|
five pages, same id+class inventory, same DOM order;
|
||||||
#sync-btn hidden, #sign-out-btn hidden) on all five pages — and the
|
* anonymous: brand + nav [Chat] (#nav-sources / #nav-git-sources /
|
||||||
steering toggle + panel are ABSENT from the DOM (phase 16 "absent,
|
#nav-tuning hidden — locked A10 UI revision) + #new-chat-btn +
|
||||||
not hidden" treatment, carried into phase 34 task 01; test_admin_auth
|
#sign-in-link (with #sync-btn hidden, #sign-out-btn hidden) on all
|
||||||
pins it).
|
five pages — and the steering toggle + panel are ABSENT from the DOM
|
||||||
|
(phase 16 "absent, not hidden" treatment, carried into phase 34 task
|
||||||
|
01; test_admin_auth pins it).
|
||||||
|
|
||||||
Normalization for the inventory comparison: the current-page ``is-active``
|
Normalization for the inventory comparison: the current-page ``is-active``
|
||||||
nav marker and the sign-in ``?next=`` value legitimately differ per page,
|
nav marker and the sign-in ``?next=`` value legitimately differ per page,
|
||||||
@@ -214,6 +216,9 @@ def _visit(page: Page, app_url: str, name: str, url: str, admin: bool) -> list[s
|
|||||||
expect(page.locator(".app-nav a[href='/']")).to_be_visible() # Chat
|
expect(page.locator(".app-nav a[href='/']")).to_be_visible() # Chat
|
||||||
if admin:
|
if admin:
|
||||||
expect(page.locator("#nav-sources")).to_be_visible()
|
expect(page.locator("#nav-sources")).to_be_visible()
|
||||||
|
# Phase 35: the fourth admin-only nav link (Git sources) is
|
||||||
|
# revealed on every page, between Sources and Tuning.
|
||||||
|
expect(page.locator("#nav-git-sources")).to_be_visible()
|
||||||
expect(page.locator("#nav-tuning")).to_be_visible()
|
expect(page.locator("#nav-tuning")).to_be_visible()
|
||||||
expect(page.locator("#steering-toggle")).to_be_visible()
|
expect(page.locator("#steering-toggle")).to_be_visible()
|
||||||
expect(page.locator("#sync-btn")).to_be_visible()
|
expect(page.locator("#sync-btn")).to_be_visible()
|
||||||
@@ -223,6 +228,7 @@ def _visit(page: Page, app_url: str, name: str, url: str, admin: bool) -> list[s
|
|||||||
# Locked A10 UI revision: admin-only links ship hidden, never
|
# Locked A10 UI revision: admin-only links ship hidden, never
|
||||||
# revealed for anonymous…
|
# revealed for anonymous…
|
||||||
expect(page.locator("#nav-sources")).to_be_hidden()
|
expect(page.locator("#nav-sources")).to_be_hidden()
|
||||||
|
expect(page.locator("#nav-git-sources")).to_be_hidden()
|
||||||
expect(page.locator("#nav-tuning")).to_be_hidden()
|
expect(page.locator("#nav-tuning")).to_be_hidden()
|
||||||
expect(page.locator("#sync-btn")).to_be_hidden()
|
expect(page.locator("#sync-btn")).to_be_hidden()
|
||||||
expect(page.locator("#sign-out-btn")).to_be_hidden()
|
expect(page.locator("#sign-out-btn")).to_be_hidden()
|
||||||
@@ -281,6 +287,7 @@ def _admin_login_page_inventory(page: Page, app_url: str) -> list[str]:
|
|||||||
_wait_settled(page, admin=True)
|
_wait_settled(page, admin=True)
|
||||||
expect(page.locator(".app-nav a[href='/']")).to_be_visible()
|
expect(page.locator(".app-nav a[href='/']")).to_be_visible()
|
||||||
expect(page.locator("#nav-sources")).to_be_visible()
|
expect(page.locator("#nav-sources")).to_be_visible()
|
||||||
|
expect(page.locator("#nav-git-sources")).to_be_visible()
|
||||||
expect(page.locator("#nav-tuning")).to_be_visible()
|
expect(page.locator("#nav-tuning")).to_be_visible()
|
||||||
expect(page.locator("#steering-toggle")).to_be_visible()
|
expect(page.locator("#steering-toggle")).to_be_visible()
|
||||||
expect(page.locator("#sync-btn")).to_be_visible()
|
expect(page.locator("#sync-btn")).to_be_visible()
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ def test_suggestions_honors_bor_suggestions_env_override(monkeypatch) -> None:
|
|||||||
("/document.html", "Brain of Reese"), # phase 10: viewer page
|
("/document.html", "Brain of Reese"), # phase 10: viewer page
|
||||||
("/login.html", "Sign in"), # phase 16: admin sign-in page
|
("/login.html", "Sign in"), # phase 16: admin sign-in page
|
||||||
("/tuning.html", "Global Tuning"), # phase 27: global tuning page
|
("/tuning.html", "Global Tuning"), # phase 27: global tuning page
|
||||||
|
("/git-sources.html", "Git sources"), # phase 35: admin git sources page
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_html_pages_served_locally_no_cdn(client, path: str, marker: str) -> None:
|
def test_html_pages_served_locally_no_cdn(client, path: str, marker: str) -> None:
|
||||||
@@ -94,7 +95,7 @@ def test_index_page_no_cache_with_versioned_asset_refs(client) -> None:
|
|||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"path",
|
"path",
|
||||||
["/sources.html", "/document.html", "/login.html", "/tuning.html"],
|
["/sources.html", "/document.html", "/login.html", "/tuning.html", "/git-sources.html"],
|
||||||
)
|
)
|
||||||
def test_html_pages_no_cache_with_versioned_refs(client, path: str) -> None:
|
def test_html_pages_no_cache_with_versioned_refs(client, path: str) -> None:
|
||||||
"""Each of the other four pages revalidates and carries at least one
|
"""Each of the other four pages revalidates and carries at least one
|
||||||
@@ -146,6 +147,7 @@ def test_styles_and_js_served(client) -> None:
|
|||||||
assert client.get("/assets/login.js").status_code == 200 # phase 16: login page
|
assert client.get("/assets/login.js").status_code == 200 # phase 16: login page
|
||||||
assert client.get("/assets/document-modal.js").status_code == 200 # phase 26: modal module
|
assert client.get("/assets/document-modal.js").status_code == 200 # phase 26: modal module
|
||||||
assert client.get("/assets/tuning.js").status_code == 200 # phase 27: tuning page
|
assert client.get("/assets/tuning.js").status_code == 200 # phase 27: tuning page
|
||||||
|
assert client.get("/assets/git-sources.js").status_code == 200 # phase 35: git sources page
|
||||||
|
|
||||||
|
|
||||||
# Emoji code points banned from UI chrome (phase 08): the pictograph
|
# Emoji code points banned from UI chrome (phase 08): the pictograph
|
||||||
@@ -177,12 +179,14 @@ def _find_emoji(text: str) -> list[str]:
|
|||||||
"/document.html",
|
"/document.html",
|
||||||
"/login.html", # phase 16
|
"/login.html", # phase 16
|
||||||
"/tuning.html", # phase 27
|
"/tuning.html", # phase 27
|
||||||
|
"/git-sources.html", # phase 35
|
||||||
"/assets/app.js",
|
"/assets/app.js",
|
||||||
"/assets/sources.js",
|
"/assets/sources.js",
|
||||||
"/assets/markdown.js",
|
"/assets/markdown.js",
|
||||||
"/assets/document.js",
|
"/assets/document.js",
|
||||||
"/assets/login.js", # phase 16
|
"/assets/login.js", # phase 16
|
||||||
"/assets/document-modal.js", # phase 26: the document modal module
|
"/assets/document-modal.js", # phase 26: the document modal module
|
||||||
|
"/assets/git-sources.js", # phase 35: the git sources page module
|
||||||
"/assets/styles.css",
|
"/assets/styles.css",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,302 @@
|
|||||||
|
"""Integration: the admin git-sources CRUD API (phase 35, task 02).
|
||||||
|
|
||||||
|
Real Postgres (``podman compose up -d db``); the ``BOR_GIT_SOURCES``
|
||||||
|
fallback is exercised deterministically by monkeypatching the router's
|
||||||
|
``get_settings`` with a fresh ``Settings(_env_file=None, git_sources=…)``
|
||||||
|
(same pattern as ``test_sync_api.py`` — the dev ``.env`` never leaks in).
|
||||||
|
|
||||||
|
Contract under test:
|
||||||
|
|
||||||
|
* anonymous → 403 ``{"detail": "admin only"}`` on GET, POST, and DELETE
|
||||||
|
(phase 16 pattern, same as ``/api/sync``);
|
||||||
|
* GET — empty table + env set → the env rows with ``from_env: true`` and
|
||||||
|
null ``id``/``added_at``; empty table + empty env → ``sources: []``
|
||||||
|
with ``from_env: true``; any DB rows → ``from_env: false`` and the env
|
||||||
|
var is ignored (the phase's locked decision); DB rows ordered by
|
||||||
|
``(added_at, id)``;
|
||||||
|
* POST — 201 stored trimmed; duplicate (even with different surrounding
|
||||||
|
whitespace) → 409 with a generic detail that never echoes the URL
|
||||||
|
(credential safety), including when only the DB unique index catches
|
||||||
|
it; bad shape / blank / >500 chars → 422, also input-free;
|
||||||
|
* DELETE — 204 and gone; an emptied table falls back to the env list
|
||||||
|
again; unknown id → 404.
|
||||||
|
|
||||||
|
``git_sources`` is global state: truncated around every test.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import select, text
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api import git_sources as git_sources_api
|
||||||
|
from app.config import Settings
|
||||||
|
from app.models import GitSource
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def clean_git_sources(db: Session) -> Iterator[None]:
|
||||||
|
"""The stored list is global state: reset around every test."""
|
||||||
|
db.execute(text("TRUNCATE git_sources"))
|
||||||
|
db.commit()
|
||||||
|
yield
|
||||||
|
db.execute(text("TRUNCATE git_sources"))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _settings(git_sources: str = "") -> Settings:
|
||||||
|
"""Fresh settings with the ``.env`` file ignored; the explicit kwarg
|
||||||
|
beats any process env leaks (test_sync_api pattern)."""
|
||||||
|
return Settings(_env_file=None, git_sources=git_sources) # pyright: ignore[reportCallIssue]
|
||||||
|
|
||||||
|
|
||||||
|
# --- anonymous -------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_gets_403_on_all_routes(client: TestClient, db: Session) -> None:
|
||||||
|
r = client.get("/api/git-sources")
|
||||||
|
assert r.status_code == 403
|
||||||
|
assert r.json() == {"detail": "admin only"}
|
||||||
|
r = client.post("/api/git-sources", json={"url": "https://anon.example.com/x.git"})
|
||||||
|
assert r.status_code == 403
|
||||||
|
assert r.json() == {"detail": "admin only"}
|
||||||
|
r = client.delete(f"/api/git-sources/{uuid.uuid4()}")
|
||||||
|
assert r.status_code == 403
|
||||||
|
assert r.json() == {"detail": "admin only"}
|
||||||
|
# Nothing landed in the table.
|
||||||
|
assert db.execute(text("SELECT count(*) FROM git_sources")).scalar_one() == 0
|
||||||
|
|
||||||
|
|
||||||
|
# --- GET: env fallback -----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_empty_table_with_env_returns_env_rows(
|
||||||
|
admin_client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
git_sources_api,
|
||||||
|
"get_settings",
|
||||||
|
lambda: _settings("https://a.example.com/one.git, git@b.example.com:two.git ,"),
|
||||||
|
)
|
||||||
|
|
||||||
|
r = admin_client.get("/api/git-sources")
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["from_env"] is True
|
||||||
|
# Whitespace-trimmed, empty entries dropped, order preserved; null ids.
|
||||||
|
assert body["sources"] == [
|
||||||
|
{"id": None, "url": "https://a.example.com/one.git", "added_at": None},
|
||||||
|
{"id": None, "url": "git@b.example.com:two.git", "added_at": None},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_empty_table_with_empty_env_returns_empty_list(
|
||||||
|
admin_client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(git_sources_api, "get_settings", lambda: _settings())
|
||||||
|
|
||||||
|
r = admin_client.get("/api/git-sources")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json() == {"sources": [], "from_env": True}
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_orders_db_rows_by_added_at_then_id(admin_client: TestClient, db: Session) -> None:
|
||||||
|
base = datetime.now(UTC) - timedelta(hours=3)
|
||||||
|
db.add_all(
|
||||||
|
[
|
||||||
|
GitSource(url="https://example.com/oldest.git", added_at=base),
|
||||||
|
GitSource(url="https://example.com/second.git", added_at=base + timedelta(hours=1)),
|
||||||
|
GitSource(url="https://example.com/newest.git", added_at=base + timedelta(hours=2)),
|
||||||
|
# Same transaction → identical server-stamped added_at (≈ now,
|
||||||
|
# after the explicit rows): the id tie-break decides their order.
|
||||||
|
GitSource(url="https://example.com/tie-a.git"),
|
||||||
|
GitSource(url="https://example.com/tie-b.git"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
tie_rows = db.scalars(
|
||||||
|
select(GitSource).where(GitSource.url.like("https://example.com/tie-%"))
|
||||||
|
).all()
|
||||||
|
tie_ids = sorted(row.id for row in tie_rows)
|
||||||
|
|
||||||
|
body = admin_client.get("/api/git-sources").json()
|
||||||
|
assert body["from_env"] is False
|
||||||
|
assert [s["url"] for s in body["sources"][:3]] == [
|
||||||
|
"https://example.com/oldest.git",
|
||||||
|
"https://example.com/second.git",
|
||||||
|
"https://example.com/newest.git",
|
||||||
|
]
|
||||||
|
assert [s["id"] for s in body["sources"][3:]] == [str(i) for i in tie_ids]
|
||||||
|
# DB rows carry real ids + timestamps (the env shape has neither).
|
||||||
|
for s in body["sources"]:
|
||||||
|
assert s["id"] is not None
|
||||||
|
assert s["added_at"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
# --- POST: create ----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_creates_trimmed_and_list_stops_using_env(
|
||||||
|
admin_client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
git_sources_api,
|
||||||
|
"get_settings",
|
||||||
|
lambda: _settings("https://env.example.com/env.git"),
|
||||||
|
)
|
||||||
|
|
||||||
|
r = admin_client.post("/api/git-sources", json={"url": " https://new.example.com/repo.git "})
|
||||||
|
assert r.status_code == 201
|
||||||
|
body = r.json()
|
||||||
|
assert body["url"] == "https://new.example.com/repo.git" # trimmed
|
||||||
|
uuid.UUID(body["id"])
|
||||||
|
assert body["added_at"] is not None
|
||||||
|
assert set(body) == {"id", "url", "added_at"}
|
||||||
|
|
||||||
|
# The DB row now wins: from_env False, the env URL is gone from the list.
|
||||||
|
body = admin_client.get("/api/git-sources").json()
|
||||||
|
assert body["from_env"] is False
|
||||||
|
assert [s["url"] for s in body["sources"]] == ["https://new.example.com/repo.git"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_accepts_accepted_url_shapes(admin_client: TestClient) -> None:
|
||||||
|
for url in (
|
||||||
|
"https://github.com/owner/repo.git",
|
||||||
|
"http://git.local/repo.git",
|
||||||
|
"ssh://git@example.com/repo.git",
|
||||||
|
"git@github.com:owner/repo.git",
|
||||||
|
):
|
||||||
|
r = admin_client.post("/api/git-sources", json={"url": url})
|
||||||
|
assert r.status_code == 201, f"{url} must be accepted: {r.text}"
|
||||||
|
assert r.json()["url"] == url
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_duplicate_url_returns_409_without_echoing_url(
|
||||||
|
admin_client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""The 409 detail is a fixed generic string — URLs may embed
|
||||||
|
``user:pass@`` credentials (phase 32's masking discipline)."""
|
||||||
|
monkeypatch.setattr(git_sources_api, "get_settings", lambda: _settings())
|
||||||
|
url = "https://user:secret@example.com/creds.git"
|
||||||
|
|
||||||
|
assert admin_client.post("/api/git-sources", json={"url": url}).status_code == 201
|
||||||
|
# Same URL with different surrounding whitespace: the trim makes it a
|
||||||
|
# duplicate too.
|
||||||
|
r = admin_client.post("/api/git-sources", json={"url": f" {url}\t"})
|
||||||
|
assert r.status_code == 409
|
||||||
|
detail = r.json()["detail"]
|
||||||
|
assert detail == "a git source with this URL already exists"
|
||||||
|
assert url not in detail
|
||||||
|
assert "user:secret" not in detail
|
||||||
|
# Exactly one row stored.
|
||||||
|
assert len(admin_client.get("/api/git-sources").json()["sources"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_concurrent_insert_backstop_still_409(
|
||||||
|
admin_client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""If the duplicate pre-check misses (a concurrent insert lands
|
||||||
|
between the check and the commit), the DB unique index still yields
|
||||||
|
the generic 409 — never a 500."""
|
||||||
|
monkeypatch.setattr(git_sources_api, "get_settings", lambda: _settings())
|
||||||
|
url = "https://example.com/backstop.git"
|
||||||
|
assert admin_client.post("/api/git-sources", json={"url": url}).status_code == 201
|
||||||
|
|
||||||
|
real_select = git_sources_api.select
|
||||||
|
|
||||||
|
def blind_select(*args: Any, **kwargs: Any) -> Any:
|
||||||
|
if args and args[0] is GitSource: # the duplicate pre-check
|
||||||
|
# …now never matches — only the unique index can catch it.
|
||||||
|
return real_select(GitSource).where(GitSource.url == "zz-never-matches")
|
||||||
|
return real_select(*args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(git_sources_api, "select", blind_select)
|
||||||
|
r = admin_client.post("/api/git-sources", json={"url": url})
|
||||||
|
assert r.status_code == 409
|
||||||
|
detail = r.json()["detail"]
|
||||||
|
assert detail == "a git source with this URL already exists"
|
||||||
|
assert url not in detail
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_rejects_invalid_shapes_without_echoing_input(
|
||||||
|
admin_client: TestClient, db: Session
|
||||||
|
) -> None:
|
||||||
|
"""Bad shapes are 422 with a detail that never repeats the submitted
|
||||||
|
value."""
|
||||||
|
for bad in ("not a url", "host:repo", "ftp://example.com/x.git"):
|
||||||
|
r = admin_client.post("/api/git-sources", json={"url": bad})
|
||||||
|
assert r.status_code == 422, f"{bad!r} must be rejected"
|
||||||
|
assert bad not in r.text, f"422 detail must not echo the input ({bad!r})"
|
||||||
|
# Nothing stored.
|
||||||
|
assert db.execute(text("SELECT count(*) FROM git_sources")).scalar_one() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_rejects_blank_and_oversized_urls(admin_client: TestClient, db: Session) -> None:
|
||||||
|
"""Whitespace-only (trim → empty) and >500-char URLs are 422; the
|
||||||
|
500-char boundary passes."""
|
||||||
|
assert admin_client.post("/api/git-sources", json={"url": " \t\n "}).status_code == 422
|
||||||
|
assert admin_client.post("/api/git-sources", json={"url": "x" * 501}).status_code == 422
|
||||||
|
boundary = "https://" + "x" * 492 # exactly 500 chars
|
||||||
|
assert len(boundary) == 500
|
||||||
|
assert admin_client.post("/api/git-sources", json={"url": boundary}).status_code == 201
|
||||||
|
assert db.execute(text("SELECT count(*) FROM git_sources")).scalar_one() == 1
|
||||||
|
|
||||||
|
|
||||||
|
# --- DB rows win over env ---------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_db_rows_win_over_env(admin_client: TestClient, db: Session, monkeypatch) -> None:
|
||||||
|
"""Seed a row AND set the env: the GET returns only the DB rows and
|
||||||
|
``from_env: false`` — the env var is ignored once the table has rows."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
git_sources_api, "get_settings", lambda: _settings("https://env.example.com/env.git")
|
||||||
|
)
|
||||||
|
db.add(GitSource(url="https://db.example.com/db.git"))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
body = admin_client.get("/api/git-sources").json()
|
||||||
|
assert body["from_env"] is False
|
||||||
|
assert [s["url"] for s in body["sources"]] == ["https://db.example.com/db.git"]
|
||||||
|
for s in body["sources"]:
|
||||||
|
assert s["id"] is not None
|
||||||
|
assert s["added_at"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
# --- DELETE -----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_removes_row_and_falls_back_to_env(
|
||||||
|
admin_client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
git_sources_api,
|
||||||
|
"get_settings",
|
||||||
|
lambda: _settings("https://env.example.com/env.git"),
|
||||||
|
)
|
||||||
|
created = admin_client.post("/api/git-sources", json={"url": "https://new.example.com/x.git"})
|
||||||
|
assert created.status_code == 201
|
||||||
|
|
||||||
|
assert admin_client.delete(f"/api/git-sources/{created.json()['id']}").status_code == 204
|
||||||
|
|
||||||
|
# The table is empty again → the env fallback is live once more.
|
||||||
|
body = admin_client.get("/api/git-sources").json()
|
||||||
|
assert body["from_env"] is True
|
||||||
|
assert body["sources"] == [
|
||||||
|
{"id": None, "url": "https://env.example.com/env.git", "added_at": None}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_unknown_id_returns_404(admin_client: TestClient) -> None:
|
||||||
|
r = admin_client.delete(f"/api/git-sources/{uuid.uuid4()}")
|
||||||
|
assert r.status_code == 404
|
||||||
|
assert r.json() == {"detail": "git source not found"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_invalid_id_returns_422(admin_client: TestClient) -> None:
|
||||||
|
assert admin_client.delete("/api/git-sources/not-a-uuid").status_code == 422
|
||||||
@@ -4,9 +4,14 @@ Drives ``scripts.import_docs`` end to end with a fake ``clone_or_pull`` (no
|
|||||||
real git, no network) and a recording fake ``import_sources`` (no real
|
real git, no network) and a recording fake ``import_sources`` (no real
|
||||||
DB), covering:
|
DB), covering:
|
||||||
|
|
||||||
- ``BOR_GIT_SOURCES`` set → each URL is cloned/pulled into
|
- Effective git sources set (phase 35: the shared resolver — stubbed
|
||||||
``BOR_SOURCES_DIR/<repo-name>/`` and exactly those dirs are imported.
|
here, keeping this file's no-real-DB style) → each URL is cloned/pulled
|
||||||
- ``--source`` still wins over ``BOR_GIT_SOURCES`` (no git at all).
|
into ``BOR_SOURCES_DIR/<repo-name>/`` and exactly those dirs are
|
||||||
|
imported.
|
||||||
|
- DB rows win over ``BOR_GIT_SOURCES`` (the resolver's ``db`` origin —
|
||||||
|
the env list is ignored).
|
||||||
|
- ``--source`` still wins over git sources (no git at all, no resolver
|
||||||
|
call).
|
||||||
- No git sources + no ``--source`` → the legacy ``DEFAULT_SOURCES``.
|
- No git sources + no ``--source`` → the legacy ``DEFAULT_SOURCES``.
|
||||||
- A failing git sync → exit code 1, an error naming the failing repo on
|
- A failing git sync → exit code 1, an error naming the failing repo on
|
||||||
stderr, and **zero** import attempts.
|
stderr, and **zero** import attempts.
|
||||||
@@ -91,6 +96,13 @@ def test_resolve_sources_git_urls_cloned_into_sources_dir(
|
|||||||
) -> None:
|
) -> None:
|
||||||
calls, fake = _fake_clone_factory()
|
calls, fake = _fake_clone_factory()
|
||||||
monkeypatch.setattr(import_docs, "clone_or_pull", fake)
|
monkeypatch.setattr(import_docs, "clone_or_pull", fake)
|
||||||
|
# Phase 35: resolution goes through the shared resolver (stubbed —
|
||||||
|
# this file keeps its no-real-DB style); the URLs are the env list.
|
||||||
|
monkeypatch.setattr(
|
||||||
|
import_docs,
|
||||||
|
"effective_git_sources",
|
||||||
|
lambda db: (["https://host/a/homelab.git", "git@host:user/deploy.git"], "env"),
|
||||||
|
)
|
||||||
settings = _settings(
|
settings = _settings(
|
||||||
git_sources="https://host/a/homelab.git, git@host:user/deploy.git ,",
|
git_sources="https://host/a/homelab.git, git@host:user/deploy.git ,",
|
||||||
sources_dir=str(tmp_path / "bor"),
|
sources_dir=str(tmp_path / "bor"),
|
||||||
@@ -119,7 +131,34 @@ def test_resolve_sources_cli_source_wins(
|
|||||||
assert calls == [] # git is never touched when --source is given
|
assert calls == [] # git is never touched when --source is given
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_sources_defaults_when_nothing_configured() -> None:
|
def test_resolve_sources_db_rows_win_over_env(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""Phase 35: the resolver's ``db`` origin (table has rows) — the
|
||||||
|
``BOR_GIT_SOURCES`` list must be ignored; only the DB repo is cloned."""
|
||||||
|
calls, fake = _fake_clone_factory()
|
||||||
|
monkeypatch.setattr(import_docs, "clone_or_pull", fake)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
import_docs,
|
||||||
|
"effective_git_sources",
|
||||||
|
lambda db: (["https://db.example/only.git"], "db"),
|
||||||
|
)
|
||||||
|
settings = _settings(
|
||||||
|
git_sources="https://env.example/ignored.git",
|
||||||
|
sources_dir=str(tmp_path / "bor"),
|
||||||
|
)
|
||||||
|
|
||||||
|
sources = import_docs._resolve_sources(None, settings)
|
||||||
|
|
||||||
|
assert sources == [tmp_path / "bor" / "only"]
|
||||||
|
assert calls == [("https://db.example/only.git", tmp_path / "bor" / "only")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_sources_defaults_when_nothing_configured(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
# Both origins empty (the resolver's ``([], "env")``) → legacy dirs.
|
||||||
|
monkeypatch.setattr(import_docs, "effective_git_sources", lambda db: ([], "env"))
|
||||||
sources = import_docs._resolve_sources(None, _settings())
|
sources = import_docs._resolve_sources(None, _settings())
|
||||||
assert sources == [p.expanduser() for p in import_docs.DEFAULT_SOURCES]
|
assert sources == [p.expanduser() for p in import_docs.DEFAULT_SOURCES]
|
||||||
|
|
||||||
@@ -137,6 +176,11 @@ def test_main_git_sources_clone_then_import(
|
|||||||
sources_dir=str(tmp_path / "bor"),
|
sources_dir=str(tmp_path / "bor"),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(import_docs, "get_settings", lambda: settings)
|
monkeypatch.setattr(import_docs, "get_settings", lambda: settings)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
import_docs,
|
||||||
|
"effective_git_sources",
|
||||||
|
lambda db: (["https://host/a/homelab.git", "https://host/a/deploy.git"], "env"),
|
||||||
|
)
|
||||||
calls, fake = _fake_clone_factory()
|
calls, fake = _fake_clone_factory()
|
||||||
monkeypatch.setattr(import_docs, "clone_or_pull", fake)
|
monkeypatch.setattr(import_docs, "clone_or_pull", fake)
|
||||||
fake_import = FakeImportSources()
|
fake_import = FakeImportSources()
|
||||||
@@ -191,6 +235,9 @@ def test_main_git_failure_aborts_before_import(
|
|||||||
sources_dir=str(tmp_path / "bor"),
|
sources_dir=str(tmp_path / "bor"),
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(import_docs, "get_settings", lambda: settings)
|
monkeypatch.setattr(import_docs, "get_settings", lambda: settings)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
import_docs, "effective_git_sources", lambda db: (["https://host/a/bad.git"], "env")
|
||||||
|
)
|
||||||
|
|
||||||
def failing_clone(url: str, dest: Path | str) -> Path:
|
def failing_clone(url: str, dest: Path | str) -> Path:
|
||||||
raise GitSyncError(
|
raise GitSyncError(
|
||||||
|
|||||||
@@ -5,12 +5,15 @@ Drives the **real Alembic engine** against the live dev database
|
|||||||
``test_migration_0004.py`` (information_schema assertions on the state the
|
``test_migration_0004.py`` (information_schema assertions on the state the
|
||||||
migration must leave):
|
migration must leave):
|
||||||
|
|
||||||
* upgrade to head → ``kb_overview`` exists with exactly the three columns
|
The tests target revision ``0005`` explicitly so later migrations
|
||||||
|
(0006, …) cannot break them.
|
||||||
|
|
||||||
|
* upgrade to 0005 → ``kb_overview`` exists with exactly the three columns
|
||||||
the phase locks in (``id INTEGER PK`` default 1, ``content TEXT NOT NULL``
|
the phase locks in (``id INTEGER PK`` default 1, ``content TEXT NOT NULL``
|
||||||
default ``''``, ``updated_at TIMESTAMPTZ NOT NULL`` default ``now()``),
|
default ``''``, ``updated_at TIMESTAMPTZ NOT NULL`` default ``now()``),
|
||||||
and a bare insert lands the single-row defaults (id=1, content='');
|
and a bare insert lands the single-row defaults (id=1, content='');
|
||||||
* downgrade to 0004 → the table is gone;
|
* downgrade to 0004 → the table is gone;
|
||||||
* upgrade to head again → it is back (round-trip).
|
* upgrade to 0005 again → it is back (round-trip).
|
||||||
|
|
||||||
The ``alembic`` fixture guarantees the DB ends at head even if a test
|
The ``alembic`` fixture guarantees the DB ends at head even if a test
|
||||||
fails or the process is interrupted.
|
fails or the process is interrupted.
|
||||||
@@ -65,14 +68,14 @@ def _version(db: Session) -> str | None:
|
|||||||
return db.execute(text("SELECT version_num FROM alembic_version")).scalar()
|
return db.execute(text("SELECT version_num FROM alembic_version")).scalar()
|
||||||
|
|
||||||
|
|
||||||
def test_upgrade_to_head_creates_kb_overview(db: Session, alembic: Config) -> None:
|
def test_upgrade_to_0005_creates_kb_overview(db: Session, alembic: Config) -> None:
|
||||||
"""Upgrade to head: the single-row table exists with the locked
|
"""Upgrade 0004 → 0005: the single-row table exists with the locked
|
||||||
column types, nullability, and server defaults."""
|
column types, nullability, and server defaults."""
|
||||||
command.downgrade(alembic, "0004") # start from the pre-0005 state
|
command.downgrade(alembic, "0004") # start from the pre-0005 state
|
||||||
assert _version(db) == "0004"
|
assert _version(db) == "0004"
|
||||||
|
|
||||||
command.upgrade(alembic, "head")
|
command.upgrade(alembic, "0005")
|
||||||
assert _version(db) == "0005", "alembic_version must be at 0005 (head)"
|
assert _version(db) == "0005", "alembic_version must be at 0005"
|
||||||
|
|
||||||
pk = db.execute(
|
pk = db.execute(
|
||||||
text(
|
text(
|
||||||
@@ -139,9 +142,11 @@ def test_downgrade_to_0004_drops_table(db: Session, alembic: Config) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_upgrade_round_trip_restores_table(db: Session, alembic: Config) -> None:
|
def test_upgrade_round_trip_restores_table(db: Session, alembic: Config) -> None:
|
||||||
"""Upgrade back to head after the downgrade: table + defaults are back."""
|
"""Downgrade to 0004, then upgrade back to 0005: table + defaults are
|
||||||
command.upgrade(alembic, "head")
|
back (self-contained — does not rely on a prior test's downgrade)."""
|
||||||
assert _version(db) == "0005", "round-trip upgrade must land at 0005 (head)"
|
command.downgrade(alembic, "0004")
|
||||||
|
command.upgrade(alembic, "0005")
|
||||||
|
assert _version(db) == "0005", "round-trip upgrade must land at 0005"
|
||||||
|
|
||||||
id_col = _column(db, "id")
|
id_col = _column(db, "id")
|
||||||
assert id_col is not None and id_col[2] == "1", "kb_overview.id must be back with default 1"
|
assert id_col is not None and id_col[2] == "1", "kb_overview.id must be back with default 1"
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
"""Integration: migration 0006 (git_sources) schema contract.
|
||||||
|
|
||||||
|
Drives the **real Alembic engine** against the live dev database
|
||||||
|
(``podman compose up -d db``), mirroring the style of
|
||||||
|
``test_migration_0004.py`` / ``test_migration_0005.py`` (information_schema
|
||||||
|
assertions on the state the migration must leave). The tests target
|
||||||
|
revision ``0006`` explicitly so later migrations (0007, …) cannot break
|
||||||
|
them:
|
||||||
|
|
||||||
|
* upgrade 0005 → 0006 → ``git_sources`` exists with exactly the three
|
||||||
|
columns the phase locks in (``id UUID PK``, ``url TEXT NOT NULL``,
|
||||||
|
``added_at TIMESTAMPTZ NOT NULL`` default ``now()``) plus the unique
|
||||||
|
index ``uq_git_sources_url`` (duplicate URLs rejected with an
|
||||||
|
IntegrityError);
|
||||||
|
* an insert without ``added_at`` gets the server-stamped default;
|
||||||
|
* downgrade to 0005 → the table (and its index) is gone;
|
||||||
|
* upgrade back to 0006 → it is back (round-trip).
|
||||||
|
|
||||||
|
The ``alembic`` fixture guarantees the DB ends at head even if a test
|
||||||
|
fails or the process is interrupted.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from alembic.config import Config
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from alembic import command
|
||||||
|
from app.db import db_available
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def alembic(db: Session) -> Iterator[Config]:
|
||||||
|
"""Real Alembic config bound to the dev DB (URL from app settings).
|
||||||
|
|
||||||
|
Starts at head (repairs an interrupted earlier run); teardown upgrades
|
||||||
|
to head no matter what happened, so the dev DB is never left below
|
||||||
|
head.
|
||||||
|
"""
|
||||||
|
if not db_available():
|
||||||
|
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
|
||||||
|
cfg = Config() # no alembic.ini file — env.py gets the URL from app config
|
||||||
|
cfg.set_main_option("script_location", "alembic")
|
||||||
|
command.upgrade(cfg, "head")
|
||||||
|
try:
|
||||||
|
yield cfg
|
||||||
|
finally:
|
||||||
|
command.upgrade(cfg, "head")
|
||||||
|
|
||||||
|
|
||||||
|
def _column(db: Session, column: str) -> tuple[Any, ...] | None:
|
||||||
|
"""(data_type, is_nullable, column_default) for one git_sources column."""
|
||||||
|
row = db.execute(
|
||||||
|
text(
|
||||||
|
"SELECT data_type, is_nullable, column_default"
|
||||||
|
" FROM information_schema.columns"
|
||||||
|
" WHERE table_name = 'git_sources' AND column_name = :c"
|
||||||
|
),
|
||||||
|
{"c": column},
|
||||||
|
).fetchone()
|
||||||
|
return tuple(row) if row is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def _version(db: Session) -> str | None:
|
||||||
|
return db.execute(text("SELECT version_num FROM alembic_version")).scalar()
|
||||||
|
|
||||||
|
|
||||||
|
def _unique_url_index(db: Session) -> int:
|
||||||
|
"""1 iff ``uq_git_sources_url`` exists as a UNIQUE index."""
|
||||||
|
count: Any = db.execute(
|
||||||
|
text(
|
||||||
|
"SELECT count(*) FROM pg_indexes"
|
||||||
|
" WHERE tablename = 'git_sources' AND indexname = 'uq_git_sources_url'"
|
||||||
|
" AND indexdef ILIKE 'CREATE UNIQUE%'"
|
||||||
|
)
|
||||||
|
).scalar()
|
||||||
|
assert count is not None, "pg_indexes count must be an int"
|
||||||
|
return int(count)
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_url(db: Session, url: str) -> None:
|
||||||
|
db.execute(
|
||||||
|
text("INSERT INTO git_sources (id, url) VALUES (gen_random_uuid(), :u)"),
|
||||||
|
{"u": url},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_upgrade_to_0006_creates_git_sources(db: Session, alembic: Config) -> None:
|
||||||
|
"""Upgrade 0005 → 0006: the table exists with the locked column types,
|
||||||
|
nullability, server default, primary key, and unique URL index."""
|
||||||
|
command.downgrade(alembic, "0005") # start from the pre-0006 state
|
||||||
|
assert _version(db) == "0005"
|
||||||
|
|
||||||
|
command.upgrade(alembic, "0006")
|
||||||
|
assert _version(db) == "0006", "alembic_version must be at 0006"
|
||||||
|
|
||||||
|
pk = db.execute(
|
||||||
|
text(
|
||||||
|
"SELECT column_name FROM information_schema.table_constraints tc"
|
||||||
|
" JOIN information_schema.key_column_usage kcu"
|
||||||
|
" ON tc.constraint_name = kcu.constraint_name"
|
||||||
|
" WHERE tc.table_name = 'git_sources' AND tc.constraint_type = 'PRIMARY KEY'"
|
||||||
|
)
|
||||||
|
).scalar()
|
||||||
|
assert pk == "id", "git_sources primary key must be id"
|
||||||
|
|
||||||
|
id_col = _column(db, "id")
|
||||||
|
assert id_col is not None, "git_sources.id is missing"
|
||||||
|
assert id_col[0] == "uuid", "git_sources.id must be UUID"
|
||||||
|
assert id_col[1] == "NO", "git_sources.id must be NOT NULL"
|
||||||
|
|
||||||
|
url = _column(db, "url")
|
||||||
|
assert url is not None, "git_sources.url is missing"
|
||||||
|
assert url[0] == "text", "git_sources.url must be TEXT"
|
||||||
|
assert url[1] == "NO", "git_sources.url must be NOT NULL"
|
||||||
|
|
||||||
|
added = _column(db, "added_at")
|
||||||
|
assert added is not None, "git_sources.added_at is missing"
|
||||||
|
assert added[0] == "timestamp with time zone", "git_sources.added_at must be TIMESTAMPTZ"
|
||||||
|
assert added[1] == "NO", "git_sources.added_at must be NOT NULL"
|
||||||
|
assert added[2] is not None and "now()" in added[2], (
|
||||||
|
"git_sources.added_at must have server default now()"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert _unique_url_index(db) == 1, "uq_git_sources_url unique index is missing"
|
||||||
|
|
||||||
|
|
||||||
|
def test_added_at_defaults_to_now(db: Session, alembic: Config) -> None:
|
||||||
|
"""An insert without ``added_at`` gets the server-stamped default —
|
||||||
|
the API layer never sets it itself (phase 35 task 02)."""
|
||||||
|
command.upgrade(alembic, "head")
|
||||||
|
try:
|
||||||
|
_insert_url(db, "https://git.example.com/mig-test/added-at.git")
|
||||||
|
stamped = db.execute(
|
||||||
|
text(
|
||||||
|
"SELECT added_at IS NOT NULL FROM git_sources"
|
||||||
|
" WHERE url = 'https://git.example.com/mig-test/added-at.git'"
|
||||||
|
)
|
||||||
|
).scalar()
|
||||||
|
assert stamped is True, "git_sources.added_at must be stamped by the server"
|
||||||
|
finally:
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"DELETE FROM git_sources"
|
||||||
|
" WHERE url = 'https://git.example.com/mig-test/added-at.git'"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_url_rejected(db: Session, alembic: Config) -> None:
|
||||||
|
"""The unique index is what the API's 409 relies on: a duplicate URL
|
||||||
|
raises IntegrityError."""
|
||||||
|
command.upgrade(alembic, "head")
|
||||||
|
try:
|
||||||
|
_insert_url(db, "https://git.example.com/mig-test/dup.git")
|
||||||
|
with pytest.raises(IntegrityError):
|
||||||
|
_insert_url(db, "https://git.example.com/mig-test/dup.git")
|
||||||
|
finally:
|
||||||
|
db.rollback() # the IntegrityError aborts the open transaction
|
||||||
|
db.execute(
|
||||||
|
text("DELETE FROM git_sources WHERE url = 'https://git.example.com/mig-test/dup.git'")
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_downgrade_to_0005_drops_table(db: Session, alembic: Config) -> None:
|
||||||
|
"""Downgrade to 0005: the table (and its index) is dropped (A13 —
|
||||||
|
reversible)."""
|
||||||
|
command.downgrade(alembic, "0005")
|
||||||
|
assert _version(db) == "0005"
|
||||||
|
|
||||||
|
exists = db.execute(text("SELECT to_regclass('public.git_sources') IS NOT NULL")).scalar()
|
||||||
|
assert exists is False, "git_sources must be dropped by the downgrade"
|
||||||
|
|
||||||
|
|
||||||
|
def test_upgrade_round_trip_restores_table(db: Session, alembic: Config) -> None:
|
||||||
|
"""Downgrade to 0005, then upgrade back to 0006: table, defaults, and
|
||||||
|
the unique index are back."""
|
||||||
|
command.downgrade(alembic, "0005")
|
||||||
|
command.upgrade(alembic, "0006")
|
||||||
|
assert _version(db) == "0006", "round-trip upgrade must land at 0006"
|
||||||
|
|
||||||
|
added = _column(db, "added_at")
|
||||||
|
assert added is not None and added[2] is not None and "now()" in added[2], (
|
||||||
|
"git_sources.added_at must keep its now() default after the round-trip"
|
||||||
|
)
|
||||||
|
assert _unique_url_index(db) == 1, "uq_git_sources_url must be back after the round-trip"
|
||||||
@@ -1,17 +1,26 @@
|
|||||||
"""Integration: the admin sources-sync API (phase 32, task 01).
|
"""Integration: the admin sources-sync API (phase 32, task 01; phase 35,
|
||||||
|
task 03 re-points the URL resolution at the shared resolver).
|
||||||
|
|
||||||
Covers the in-process sync runner end to end over HTTP: anonymous 403s
|
Covers the in-process sync runner end to end over HTTP: anonymous 403s
|
||||||
on both endpoints; admin idle → 202 → ``success`` with the full
|
on both endpoints; admin idle → 202 → ``success`` with the full
|
||||||
ImportSummary detail; 409 on a double trigger while a run is in flight;
|
ImportSummary detail; 409 on a double trigger while a run is in flight;
|
||||||
``GitSyncError`` → ``failed`` with the failing repo named and **zero**
|
``GitSyncError`` → ``failed`` with the failing repo named and **zero**
|
||||||
import attempts; empty ``BOR_GIT_SOURCES`` → ``failed`` loudly; an
|
import attempts; empty on *both* origins (``git_sources`` table +
|
||||||
embedding failure → ``failed`` with any credentials masked; the import
|
``BOR_GIT_SOURCES``) → ``failed`` loudly; an embedding failure →
|
||||||
always runs with ``prune=True``; and the phase-31 overview trigger is
|
``failed`` with any credentials masked; the import always runs with
|
||||||
change-gated (no ``lite`` call on an unchanged KB).
|
``prune=True``; and the phase-31 overview trigger is change-gated (no
|
||||||
|
``lite`` call on an unchanged KB).
|
||||||
|
|
||||||
|
Phase 35: the runner resolves the repos through
|
||||||
|
:func:`app.rag.git_sources.effective_git_sources` — the **real**
|
||||||
|
resolver against the **real** ``git_sources`` table (truncated around
|
||||||
|
every test), so DB-over-env and the env fallback go through the actual
|
||||||
|
indirection; the env list is driven by a fresh ``Settings`` on the
|
||||||
|
resolver's module (the dev ``.env`` never leaks in).
|
||||||
|
|
||||||
The git / import / overview layers are monkeypatched in ``app.api.sync``
|
The git / import / overview layers are monkeypatched in ``app.api.sync``
|
||||||
(same fake style as ``test_import_docs_git.py``) — no real git, no real
|
(same fake style as ``test_import_docs_git.py``) — no real git, no LLM:
|
||||||
DB, no LLM: the runner's state machine and HTTP surface are under test.
|
the runner's state machine and HTTP surface are under test.
|
||||||
|
|
||||||
The admin client is used **as a context manager** on purpose: the
|
The admin client is used **as a context manager** on purpose: the
|
||||||
background sync task lives on the app's event loop, so the loop must
|
background sync task lives on the app's event loop, so the loop must
|
||||||
@@ -22,6 +31,7 @@ request and would cancel the task on request exit.)
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
import time
|
import time
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -29,11 +39,14 @@ from pathlib import Path
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.api import sync as sync_api
|
from app.api import sync as sync_api
|
||||||
from app.config import Settings
|
from app.config import Settings
|
||||||
from app.main import app as fastapi_app
|
from app.main import app as fastapi_app
|
||||||
|
from app.models import GitSource
|
||||||
|
from app.rag import git_sources as git_sources_resolver
|
||||||
from app.rag.importer import ImportSummary
|
from app.rag.importer import ImportSummary
|
||||||
from app.rag.llm import EmbeddingError, LLMClient
|
from app.rag.llm import EmbeddingError, LLMClient
|
||||||
from scripts.git_sync import GitSyncError
|
from scripts.git_sync import GitSyncError
|
||||||
@@ -52,6 +65,18 @@ def _fresh_sync_state() -> Iterator[None]:
|
|||||||
sync_api._task = None
|
sync_api._task = None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def clean_git_sources(db: Session) -> Iterator[None]:
|
||||||
|
"""Phase 35: the runner resolves through the real ``git_sources``
|
||||||
|
table — global state, truncated around every test (the ``db``
|
||||||
|
fixture skips the file when Postgres is down)."""
|
||||||
|
db.execute(text("TRUNCATE git_sources"))
|
||||||
|
db.commit()
|
||||||
|
yield
|
||||||
|
db.execute(text("TRUNCATE git_sources"))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.fixture()
|
||||||
def sync_client() -> Iterator[TestClient]:
|
def sync_client() -> Iterator[TestClient]:
|
||||||
"""Context-managed TestClient — one app event loop across requests
|
"""Context-managed TestClient — one app event loop across requests
|
||||||
@@ -60,9 +85,29 @@ def sync_client() -> Iterator[TestClient]:
|
|||||||
yield client
|
yield client
|
||||||
|
|
||||||
|
|
||||||
def _settings(git_sources: str = "", sources_dir: str = "~/bor-sources") -> Settings:
|
def _settings(sources_dir: str = "~/bor-sources") -> Settings:
|
||||||
"""Fresh settings (no .env file); explicit kwargs beat any env leaks."""
|
"""Fresh settings (no .env file); explicit kwargs beat any env leaks.
|
||||||
return Settings(_env_file=None, git_sources=git_sources, sources_dir=sources_dir) # pyright: ignore[reportCallIssue]
|
|
||||||
|
(The ``git_sources`` kwarg is gone with phase 35 — the runner reads
|
||||||
|
the URLs from the resolver, not from its own settings; the env list
|
||||||
|
is stubbed on the resolver's module via :func:`_stub_env`.)
|
||||||
|
"""
|
||||||
|
return Settings(_env_file=None, sources_dir=sources_dir) # pyright: ignore[reportCallIssue]
|
||||||
|
|
||||||
|
|
||||||
|
def _stub_env(monkeypatch: pytest.MonkeyPatch, git_sources: str = "") -> None:
|
||||||
|
"""The resolver's env fallback, driven by a fresh ``Settings`` (the
|
||||||
|
dev ``.env`` never leaks in — the task-02 pattern)."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
git_sources_resolver,
|
||||||
|
"get_settings",
|
||||||
|
lambda: Settings(_env_file=None, git_sources=git_sources), # pyright: ignore[reportCallIssue]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _seed(db: Session, url: str) -> None:
|
||||||
|
db.add(GitSource(url=url))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
def _login(client: TestClient) -> None:
|
def _login(client: TestClient) -> None:
|
||||||
@@ -157,13 +202,15 @@ def test_anonymous_gets_403_on_both_endpoints(client: TestClient) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_admin_sync_success_reports_full_detail(
|
def test_admin_sync_success_reports_full_detail(
|
||||||
sync_client: TestClient, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
sync_client: TestClient, monkeypatch: pytest.MonkeyPatch, db: Session, tmp_path: Path
|
||||||
) -> None:
|
) -> None:
|
||||||
repo_url = f"file://{tmp_path / 'repo.git'}"
|
repo_url = f"file://{tmp_path / 'repo.git'}"
|
||||||
|
_seed(db, repo_url) # the phase-35 resolver picks the DB row up
|
||||||
|
_stub_env(monkeypatch) # env must not matter once the table has a row
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
sync_api,
|
sync_api,
|
||||||
"get_settings",
|
"get_settings",
|
||||||
lambda: _settings(git_sources=repo_url, sources_dir=str(tmp_path / "bor")),
|
lambda: _settings(sources_dir=str(tmp_path / "bor")),
|
||||||
)
|
)
|
||||||
clone_calls, fake_clone = _fake_clone()
|
clone_calls, fake_clone = _fake_clone()
|
||||||
monkeypatch.setattr(sync_api, "clone_or_pull", fake_clone)
|
monkeypatch.setattr(sync_api, "clone_or_pull", fake_clone)
|
||||||
@@ -214,14 +261,16 @@ def test_admin_sync_success_reports_full_detail(
|
|||||||
|
|
||||||
|
|
||||||
def test_unchanged_kb_skips_overview_refresh(
|
def test_unchanged_kb_skips_overview_refresh(
|
||||||
sync_client: TestClient, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
sync_client: TestClient, monkeypatch: pytest.MonkeyPatch, db: Session, tmp_path: Path
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Phase-31 trigger is change-gated: added + updated == 0 → no ``lite`` call."""
|
"""Phase-31 trigger is change-gated: added + updated == 0 → no ``lite`` call."""
|
||||||
repo_url = f"file://{tmp_path / 'repo.git'}"
|
repo_url = f"file://{tmp_path / 'repo.git'}"
|
||||||
|
_seed(db, repo_url)
|
||||||
|
_stub_env(monkeypatch)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
sync_api,
|
sync_api,
|
||||||
"get_settings",
|
"get_settings",
|
||||||
lambda: _settings(git_sources=repo_url, sources_dir=str(tmp_path / "bor")),
|
lambda: _settings(sources_dir=str(tmp_path / "bor")),
|
||||||
)
|
)
|
||||||
_, fake_clone = _fake_clone()
|
_, fake_clone = _fake_clone()
|
||||||
monkeypatch.setattr(sync_api, "clone_or_pull", fake_clone)
|
monkeypatch.setattr(sync_api, "clone_or_pull", fake_clone)
|
||||||
@@ -244,13 +293,15 @@ def test_unchanged_kb_skips_overview_refresh(
|
|||||||
|
|
||||||
|
|
||||||
def test_double_trigger_while_running_returns_409(
|
def test_double_trigger_while_running_returns_409(
|
||||||
sync_client: TestClient, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
sync_client: TestClient, monkeypatch: pytest.MonkeyPatch, db: Session, tmp_path: Path
|
||||||
) -> None:
|
) -> None:
|
||||||
repo_url = f"file://{tmp_path / 'repo.git'}"
|
repo_url = f"file://{tmp_path / 'repo.git'}"
|
||||||
|
_seed(db, repo_url)
|
||||||
|
_stub_env(monkeypatch)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
sync_api,
|
sync_api,
|
||||||
"get_settings",
|
"get_settings",
|
||||||
lambda: _settings(git_sources=repo_url, sources_dir=str(tmp_path / "bor")),
|
lambda: _settings(sources_dir=str(tmp_path / "bor")),
|
||||||
)
|
)
|
||||||
_, fake_clone = _fake_clone()
|
_, fake_clone = _fake_clone()
|
||||||
monkeypatch.setattr(sync_api, "clone_or_pull", fake_clone)
|
monkeypatch.setattr(sync_api, "clone_or_pull", fake_clone)
|
||||||
@@ -282,13 +333,15 @@ def test_double_trigger_while_running_returns_409(
|
|||||||
|
|
||||||
|
|
||||||
def test_git_failure_marks_failed_and_skips_import(
|
def test_git_failure_marks_failed_and_skips_import(
|
||||||
sync_client: TestClient, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
sync_client: TestClient, monkeypatch: pytest.MonkeyPatch, db: Session, tmp_path: Path
|
||||||
) -> None:
|
) -> None:
|
||||||
repo_url = f"file://{tmp_path / 'bad.git'}"
|
repo_url = f"file://{tmp_path / 'bad.git'}"
|
||||||
|
_seed(db, repo_url)
|
||||||
|
_stub_env(monkeypatch)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
sync_api,
|
sync_api,
|
||||||
"get_settings",
|
"get_settings",
|
||||||
lambda: _settings(git_sources=repo_url, sources_dir=str(tmp_path / "bor")),
|
lambda: _settings(sources_dir=str(tmp_path / "bor")),
|
||||||
)
|
)
|
||||||
|
|
||||||
def failing_clone(url: str, dest: Path | str) -> Path:
|
def failing_clone(url: str, dest: Path | str) -> Path:
|
||||||
@@ -322,11 +375,14 @@ def test_git_failure_marks_failed_and_skips_import(
|
|||||||
def test_no_git_sources_configured_fails_loudly(
|
def test_no_git_sources_configured_fails_loudly(
|
||||||
sync_client: TestClient, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
sync_client: TestClient, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
) -> None:
|
) -> None:
|
||||||
|
"""Both origins empty (the truncate fixture + a blank env) → the
|
||||||
|
fail-loud error names *both* (phase 35)."""
|
||||||
|
# Whitespace-only is just as unconfigured as empty.
|
||||||
|
_stub_env(monkeypatch, " , ")
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
sync_api,
|
sync_api,
|
||||||
"get_settings",
|
"get_settings",
|
||||||
# Whitespace-only is just as unconfigured as empty.
|
lambda: _settings(sources_dir=str(tmp_path / "bor")),
|
||||||
lambda: _settings(git_sources=" , ", sources_dir=str(tmp_path / "bor")),
|
|
||||||
)
|
)
|
||||||
clone_calls, fake_clone = _fake_clone()
|
clone_calls, fake_clone = _fake_clone()
|
||||||
monkeypatch.setattr(sync_api, "clone_or_pull", fake_clone)
|
monkeypatch.setattr(sync_api, "clone_or_pull", fake_clone)
|
||||||
@@ -337,19 +393,87 @@ def test_no_git_sources_configured_fails_loudly(
|
|||||||
assert sync_client.post("/api/sync").status_code == 202
|
assert sync_client.post("/api/sync").status_code == 202
|
||||||
|
|
||||||
body = _poll(sync_client, "failed")
|
body = _poll(sync_client, "failed")
|
||||||
assert body["error"] == "no git sources configured (BOR_GIT_SOURCES)"
|
assert body["error"] == (
|
||||||
|
"no git sources configured (git_sources table empty and BOR_GIT_SOURCES unset)"
|
||||||
|
)
|
||||||
assert clone_calls == [] # git is never touched
|
assert clone_calls == [] # git is never touched
|
||||||
assert fake_import.sources == []
|
assert fake_import.sources == []
|
||||||
|
|
||||||
|
|
||||||
def test_import_error_is_reported_with_credentials_masked(
|
def test_db_rows_win_over_env(
|
||||||
sync_client: TestClient, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
sync_client: TestClient,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
db: Session,
|
||||||
|
tmp_path: Path,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
) -> None:
|
) -> None:
|
||||||
repo_url = f"file://{tmp_path / 'repo.git'}"
|
"""Phase 35: a stored row beats ``BOR_GIT_SOURCES`` — only the DB URL
|
||||||
|
is cloned, and the started log names the origin."""
|
||||||
|
db_url = "https://db.example.com/managed.git"
|
||||||
|
_seed(db, db_url)
|
||||||
|
_stub_env(monkeypatch, "https://env.example.com/ignored.git")
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
sync_api,
|
sync_api,
|
||||||
"get_settings",
|
"get_settings",
|
||||||
lambda: _settings(git_sources=repo_url, sources_dir=str(tmp_path / "bor")),
|
lambda: _settings(sources_dir=str(tmp_path / "bor")),
|
||||||
|
)
|
||||||
|
clone_calls, fake_clone = _fake_clone()
|
||||||
|
monkeypatch.setattr(sync_api, "clone_or_pull", fake_clone)
|
||||||
|
fake_import = FakeImportSources(ImportSummary(files=1, added=1))
|
||||||
|
monkeypatch.setattr(sync_api, "import_sources", fake_import)
|
||||||
|
monkeypatch.setattr(sync_api, "regenerate_overview", FakeOverview(ok=True))
|
||||||
|
|
||||||
|
_login(sync_client)
|
||||||
|
with caplog.at_level(logging.INFO, logger="app.api.sync"):
|
||||||
|
assert sync_client.post("/api/sync").status_code == 202
|
||||||
|
body = _poll(sync_client, "success")
|
||||||
|
|
||||||
|
assert clone_calls == [(db_url, tmp_path / "bor" / "managed")]
|
||||||
|
assert fake_import.sources == [[tmp_path / "bor" / "managed"]]
|
||||||
|
assert "env.example.com" not in str(body) # the env URL never reaches the UI
|
||||||
|
assert any("sync: started repos=1 origin=db" in r.getMessage() for r in caplog.records)
|
||||||
|
|
||||||
|
|
||||||
|
def test_env_fallback_when_table_empty(
|
||||||
|
sync_client: TestClient,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
caplog: pytest.LogCaptureFixture,
|
||||||
|
) -> None:
|
||||||
|
"""Phase 35: with the table empty (the truncate fixture), the
|
||||||
|
``BOR_GIT_SOURCES`` list is what gets cloned — origin ``env``."""
|
||||||
|
env_url = "https://env.example.com/fallback.git"
|
||||||
|
_stub_env(monkeypatch, env_url)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
sync_api,
|
||||||
|
"get_settings",
|
||||||
|
lambda: _settings(sources_dir=str(tmp_path / "bor")),
|
||||||
|
)
|
||||||
|
clone_calls, fake_clone = _fake_clone()
|
||||||
|
monkeypatch.setattr(sync_api, "clone_or_pull", fake_clone)
|
||||||
|
fake_import = FakeImportSources(ImportSummary(files=1, added=1))
|
||||||
|
monkeypatch.setattr(sync_api, "import_sources", fake_import)
|
||||||
|
monkeypatch.setattr(sync_api, "regenerate_overview", FakeOverview(ok=True))
|
||||||
|
|
||||||
|
_login(sync_client)
|
||||||
|
with caplog.at_level(logging.INFO, logger="app.api.sync"):
|
||||||
|
assert sync_client.post("/api/sync").status_code == 202
|
||||||
|
_poll(sync_client, "success")
|
||||||
|
|
||||||
|
assert clone_calls == [(env_url, tmp_path / "bor" / "fallback")]
|
||||||
|
assert any("sync: started repos=1 origin=env" in r.getMessage() for r in caplog.records)
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_error_is_reported_with_credentials_masked(
|
||||||
|
sync_client: TestClient, monkeypatch: pytest.MonkeyPatch, db: Session, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
repo_url = f"file://{tmp_path / 'repo.git'}"
|
||||||
|
_seed(db, repo_url)
|
||||||
|
_stub_env(monkeypatch)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
sync_api,
|
||||||
|
"get_settings",
|
||||||
|
lambda: _settings(sources_dir=str(tmp_path / "bor")),
|
||||||
)
|
)
|
||||||
_, fake_clone = _fake_clone()
|
_, fake_clone = _fake_clone()
|
||||||
monkeypatch.setattr(sync_api, "clone_or_pull", fake_clone)
|
monkeypatch.setattr(sync_api, "clone_or_pull", fake_clone)
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"""Unit: the shared git-source resolver (phase 35, task 03).
|
||||||
|
|
||||||
|
``effective_git_sources`` is driven with a stubbed session (no Postgres)
|
||||||
|
and a fresh ``Settings(_env_file=None)`` env (monkeypatched into the
|
||||||
|
resolver's module — the dev ``.env`` never leaks in, same pattern as the
|
||||||
|
integration suites): DB rows win in ``(added_at, id)`` order (env
|
||||||
|
ignored), the ``BOR_GIT_SOURCES`` list is a fallback only while the
|
||||||
|
table is empty (phase-28 CSV parse reused), and both-empty yields
|
||||||
|
``([], "env")`` so the callers keep their fail-loud behavior.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.config import Settings
|
||||||
|
from app.models import GitSource
|
||||||
|
from app.rag import git_sources as resolver
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeScalars:
|
||||||
|
"""The ``.scalars(stmt).all()`` tail of the resolver's query."""
|
||||||
|
|
||||||
|
def __init__(self, rows: list[GitSource]) -> None:
|
||||||
|
self._rows = rows
|
||||||
|
|
||||||
|
def all(self) -> list[GitSource]:
|
||||||
|
return self._rows
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeSession:
|
||||||
|
"""Just enough of a SQLAlchemy session for the resolver (test_steering
|
||||||
|
pattern). Records the statement so the ordering can be asserted."""
|
||||||
|
|
||||||
|
def __init__(self, rows: list[GitSource]) -> None:
|
||||||
|
self._rows = rows
|
||||||
|
self.statements: list[Any] = []
|
||||||
|
|
||||||
|
def scalars(self, stmt: Any) -> _FakeScalars:
|
||||||
|
self.statements.append(stmt)
|
||||||
|
return _FakeScalars(self._rows)
|
||||||
|
|
||||||
|
|
||||||
|
def _stub_env(monkeypatch: pytest.MonkeyPatch, git_sources: str) -> None:
|
||||||
|
"""Point the resolver's env fallback at a fresh Settings (no .env)."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
resolver,
|
||||||
|
"get_settings",
|
||||||
|
lambda: Settings(_env_file=None, git_sources=git_sources), # pyright: ignore[reportCallIssue]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- the three branches ----------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_db_rows_win_over_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Seeded table + env set → the DB list, origin ``db`` (env ignored)."""
|
||||||
|
_stub_env(monkeypatch, "https://env.example/ignored.git")
|
||||||
|
session = _FakeSession([GitSource(url="https://db.example/a.git"), GitSource(url="https://db.example/b.git")])
|
||||||
|
|
||||||
|
urls, origin = resolver.effective_git_sources(session) # pyright: ignore[reportArgumentType]
|
||||||
|
|
||||||
|
assert urls == ["https://db.example/a.git", "https://db.example/b.git"]
|
||||||
|
assert origin == "db"
|
||||||
|
|
||||||
|
|
||||||
|
def test_env_fallback_while_table_empty(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Empty table + env set → the env list, origin ``env``.
|
||||||
|
|
||||||
|
The CSV parse is ``Settings.git_source_list`` itself (phase 28):
|
||||||
|
whitespace-trimmed, empty entries dropped, order preserved.
|
||||||
|
"""
|
||||||
|
_stub_env(monkeypatch, " https://env.example/one.git , ,git@env.example:two.git ")
|
||||||
|
session = _FakeSession([])
|
||||||
|
|
||||||
|
urls, origin = resolver.effective_git_sources(session) # pyright: ignore[reportArgumentType]
|
||||||
|
|
||||||
|
assert urls == ["https://env.example/one.git", "git@env.example:two.git"]
|
||||||
|
assert origin == "env"
|
||||||
|
|
||||||
|
|
||||||
|
def test_both_empty_returns_empty_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Empty table + unset env → ``([], "env")`` — the callers fail loudly."""
|
||||||
|
_stub_env(monkeypatch, " , ") # whitespace-only is just as unconfigured as empty
|
||||||
|
session = _FakeSession([])
|
||||||
|
|
||||||
|
urls, origin = resolver.effective_git_sources(session) # pyright: ignore[reportArgumentType]
|
||||||
|
|
||||||
|
assert urls == []
|
||||||
|
assert origin == "env"
|
||||||
|
|
||||||
|
|
||||||
|
# --- the ordering ----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_db_rows_ordered_by_added_at_then_id(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""The statement orders by ``(added_at, id)`` — oldest first, the id
|
||||||
|
tie-break deciding same-timestamp inserts (matches the API's GET)."""
|
||||||
|
_stub_env(monkeypatch, "")
|
||||||
|
session = _FakeSession([GitSource(url="https://db.example/a.git")])
|
||||||
|
|
||||||
|
resolver.effective_git_sources(session) # pyright: ignore[reportArgumentType]
|
||||||
|
|
||||||
|
assert len(session.statements) == 1
|
||||||
|
compiled = str(session.statements[0].compile(compile_kwargs={"literal_binds": True}))
|
||||||
|
assert re.search(
|
||||||
|
r"ORDER BY\s+git_sources\.added_at ASC,\s+git_sources\.id ASC", compiled
|
||||||
|
), compiled
|
||||||
Reference in New Issue
Block a user