feat(docs): save chat answers as docs — edit screen, commit + push to the .env docs branch
This commit is contained in:
@@ -82,6 +82,16 @@ BOR_IMPORT_EXTENSIONS=md,markdown,txt,yaml,yml,json,py,container,network,volume,
|
||||
# BOR_UPLOAD_MAX_MB=512 # caps BOTH the compressed upload and the total
|
||||
# extracted bytes (zip-bomb guard); must be > 0
|
||||
|
||||
# --- Docs push (phase 59: save a chat answer as documentation) ---
|
||||
# The git repo chat answers can be committed to — any remote (URL or
|
||||
# local path). While empty, the "Save as doc" action is hidden and the
|
||||
# push endpoint 409s. Commits land on BOR_DOCS_BRANCH (push --ff-only);
|
||||
# open the PR yourself.
|
||||
# BOR_DOCS_REPO=/path/to/docs-repo
|
||||
# BOR_DOCS_BRANCH=bor-docs
|
||||
# BOR_DOCS_BASE_BRANCH=main
|
||||
# BOR_DOCS_WORK_DIR=~/bor-docs
|
||||
|
||||
# --- Admin & sign-in (single-admin password login; BOTH required) ---
|
||||
# The app refuses to start while either is empty (names the missing
|
||||
# variable(s) — README "Admin & sign-in"). Generate the secret with:
|
||||
|
||||
+2
-1
@@ -22,10 +22,11 @@ RUN mkdir -p /out/assets \
|
||||
&& esbuild ./assets/git-sources.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/git-sources.js \
|
||||
&& esbuild ./assets/history.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/history.js \
|
||||
&& esbuild ./assets/shared.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/shared.js \
|
||||
&& esbuild ./assets/doc-edit.js --bundle --minify --format=esm --target=es2022 --outfile=/out/assets/doc-edit.js \
|
||||
&& esbuild ./assets/brand.js --minify --outfile=/out/assets/brand.js \
|
||||
&& esbuild ./assets/markdown.js --minify --outfile=/out/assets/markdown.js \
|
||||
&& esbuild ./assets/styles.css --minify --outfile=/out/assets/styles.css \
|
||||
&& cp ./index.html ./sources.html ./document.html ./login.html ./tuning.html ./git-sources.html ./history.html ./shared.html /out/
|
||||
&& cp ./index.html ./sources.html ./document.html ./login.html ./tuning.html ./git-sources.html ./history.html ./shared.html ./doc-edit.html /out/
|
||||
|
||||
# ---------- Stage 2: python dependencies ----------
|
||||
FROM docker.io/python:3.12-slim AS python
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""doc_drafts: server-side drafts of chat answers saved as docs (phase 59)
|
||||
|
||||
Revision ID: 0011
|
||||
Revises: 0010
|
||||
Create Date: 2026-09-01
|
||||
|
||||
Phase 59 (save chat answers as docs — edit screen → commit + push to the
|
||||
.env docs branch, owner revision D3 2026-08-31: no PR tooling — one
|
||||
additive, reversible table, no other schema change, A13):
|
||||
|
||||
* ``doc_drafts`` — one row per "Save as doc" action: the long answer
|
||||
body must live on the **server**, never in a URL. ``token`` is an
|
||||
unguessable 128-bit ``uuid4`` — the edit screen's URL credential
|
||||
(``/doc-edit.html?draft=<token>``, the share-token trust model,
|
||||
phase 51) — UNIQUE (``ix_doc_drafts_token``) + NOT NULL. Unlike the
|
||||
NULLable ``saved_chats.share_token`` there is no "un-drafted" state,
|
||||
so no NULLs ever occur; the unique index is the guard against
|
||||
duplicate tokens.
|
||||
* ``title`` / ``path`` / ``body`` — the editable triple (TEXT NOT NULL;
|
||||
the body is the answer's raw markdown, never HTML — the
|
||||
``bor.chat.v1`` record shape).
|
||||
* ``status`` — plain TEXT + server default ``'draft'`` (the
|
||||
``git_sources.kind`` phase-38 precedent — the ``draft`` | ``pushed``
|
||||
domain is enforced by the API layer, not a CHECK constraint).
|
||||
* ``branch`` / ``commit_sha`` — TEXT NULL: set by the push endpoint
|
||||
(task 04) when it commits + pushes the file to the ``BOR_DOCS_REPO``
|
||||
branch, recording the branch + the pushed branch's new HEAD (the UI's
|
||||
branch + sha feedback).
|
||||
* ``created_at`` / ``updated_at`` — TIMESTAMPTZ NOT NULL, stamped
|
||||
server-side (``updated_at`` bumps on every row update via the ORM
|
||||
``onupdate`` — the ``saved_chats`` precedent).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "0011"
|
||||
down_revision = "0010"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"doc_drafts",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("token", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("title", sa.Text(), nullable=False),
|
||||
sa.Column("path", sa.Text(), nullable=False),
|
||||
sa.Column("body", sa.Text(), nullable=False),
|
||||
sa.Column("status", sa.Text(), nullable=False, server_default=sa.text("'draft'")),
|
||||
sa.Column("branch", sa.Text(), nullable=True),
|
||||
sa.Column("commit_sha", sa.Text(), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.func.now(),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
# The token is the URL credential — a unique handle (the
|
||||
# saved_chats.share_token unique-index precedent, phase 51).
|
||||
op.create_index("ix_doc_drafts_token", "doc_drafts", ["token"], unique=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Safe order: drop the token index first, then the table (A13 —
|
||||
# fully reversible, no other schema change).
|
||||
op.drop_index("ix_doc_drafts_token", table_name="doc_drafts")
|
||||
op.drop_table("doc_drafts")
|
||||
+13
-4
@@ -1,4 +1,5 @@
|
||||
"""Public app metadata (display name + version) for the frontend brand layer."""
|
||||
"""Public app metadata (display name + version) for the frontend brand
|
||||
layer, plus the phase-59 docs-push flag (the "Save as doc" gating)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
@@ -9,6 +10,14 @@ router = APIRouter(tags=["config"])
|
||||
|
||||
|
||||
@router.get("/config")
|
||||
def app_config(settings: Settings = Depends(get_settings)) -> dict[str, str]: # noqa: B008
|
||||
"""Public app metadata for the frontend brand layer (phase 39)."""
|
||||
return {"app_name": settings.app_name, "version": settings.app_version}
|
||||
def app_config(settings: Settings = Depends(get_settings)) -> dict[str, str | bool]: # noqa: B008
|
||||
"""Public app metadata for the frontend brand layer (phase 39) +
|
||||
the phase-59 ``docs_repo_configured`` flag — the chat page's
|
||||
"Save as doc" button gating, surfaced the way ``app_name`` is
|
||||
(the SAME boot fetch, no new network surface). Inert false while
|
||||
``BOR_DOCS_REPO`` is empty (the feature is off, D3)."""
|
||||
return {
|
||||
"app_name": settings.app_name,
|
||||
"version": settings.app_version,
|
||||
"docs_repo_configured": settings.docs_configured,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
"""Doc-drafts API — the draft lifecycle the edit screen runs on
|
||||
(phase 59, task 02).
|
||||
|
||||
The "Save as doc" action (task 05) POSTs a completed answer here, the
|
||||
edit screen (task 06) GETs/PUTs it by token, and the push endpoint
|
||||
(task 04) commits + pushes it. A draft's long answer body lives on the
|
||||
**server** — never in a URL: the row is keyed by an unguessable 128-bit
|
||||
``uuid4`` ``token`` (the edit screen's URL credential,
|
||||
``/doc-edit.html?draft=<token>`` — the share-token trust model,
|
||||
phase 51).
|
||||
|
||||
The whole router sits behind :func:`app.core.auth.require_admin`
|
||||
(router-wide ``dependencies`` — the :mod:`app.api.steering` pattern):
|
||||
drafts are admin-only, so anonymous callers get 403 on every route.
|
||||
|
||||
Routes: ``POST /api/doc-drafts`` (create — 201, ``token = uuid4``,
|
||||
``status = "draft"``), ``GET /api/doc-drafts/{token}`` (fetch by
|
||||
token — 404 ``draft not found`` when unknown), ``PUT
|
||||
/api/doc-drafts/{token}`` (partial update — absent fields unchanged,
|
||||
``updated_at`` bumped; editing a ``pushed`` draft resets ``status``
|
||||
back to ``draft`` — the stored sha no longer describes the current
|
||||
body, so the next push re-commits; phase 59 D3 ASSUMPTION), ``POST
|
||||
/api/doc-drafts/{token}/push`` (the single mutation the edit screen
|
||||
triggers — commit + ``git push`` the draft's file to the
|
||||
``BOR_DOCS_REPO`` ``BOR_DOCS_BRANCH`` via
|
||||
:func:`app.core.docs_push.push_document`; success records
|
||||
``status`` / ``branch`` / ``commit_sha`` on the row and returns
|
||||
``DocDraftPushed``; 409 while unconfigured, 422 on a path that no
|
||||
longer passes the guard-rails, 502 on git failure with git's stderr
|
||||
in the detail — the row untouched).
|
||||
|
||||
Every ``path`` (create, update **and** push) passes the shared
|
||||
:func:`validate_draft_path` guard, so no draft can ever be created,
|
||||
edited or pushed with a path that escapes the repo root.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.core.auth import require_admin
|
||||
from app.core.docs_push import DocsPushError, push_document
|
||||
from app.db import get_db
|
||||
from app.models import DocDraft
|
||||
from app.schemas import DocDraft as DocDraftOut
|
||||
from app.schemas import (
|
||||
DocDraftCreate,
|
||||
DocDraftPushed,
|
||||
DocDraftUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/doc-drafts",
|
||||
tags=["doc-drafts"],
|
||||
dependencies=[Depends(require_admin)], # phase 59: drafts are admin-only
|
||||
)
|
||||
|
||||
|
||||
def validate_draft_path(raw: str) -> str:
|
||||
"""Guard-rail an in-repo doc path (phase 59, task 02).
|
||||
|
||||
Shared by the create/update routes **and** the push endpoint (task
|
||||
04): the file will later be written inside the ``BOR_DOCS_REPO``
|
||||
checkout, so a path that escapes the repo root is a security hole,
|
||||
not a typo. Rules (first violation wins, each 422 names its rule):
|
||||
|
||||
* non-empty after strip — a blank path names no file;
|
||||
* not absolute — ``/etc/passwd`` would leave the checkout;
|
||||
* no ``.``/``..`` components — ``../x.md`` and ``a/b/../c.md`` walk
|
||||
out of the checkout (checked via ``Path(p).parts``, so the
|
||||
traversal is rejected wherever it sits);
|
||||
* carries a file suffix (``Path(p).suffix`` non-empty) — the
|
||||
committed file must be a real file, e.g. ``docs/note.md``.
|
||||
|
||||
Returns the stripped path (the value that gets stored); raises
|
||||
:class:`fastapi.HTTPException` (422) on the first violated rule.
|
||||
"""
|
||||
path = raw.strip()
|
||||
if not path:
|
||||
raise HTTPException(status_code=422, detail="path must not be empty")
|
||||
if Path(path).is_absolute():
|
||||
raise HTTPException(status_code=422, detail="path must not be an absolute path")
|
||||
if any(part in (".", "..") for part in Path(path).parts):
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="path must not contain '.' or '..' path components",
|
||||
)
|
||||
if not Path(path).suffix:
|
||||
raise HTTPException(
|
||||
status_code=422, detail="path must carry a file suffix (e.g. docs/note.md)"
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
def _to_out(row: DocDraft) -> DocDraftOut:
|
||||
"""The full-payload response shape (create/get/put)."""
|
||||
return DocDraftOut(
|
||||
token=row.token,
|
||||
title=row.title,
|
||||
path=row.path,
|
||||
body=row.body,
|
||||
status=row.status,
|
||||
branch=row.branch,
|
||||
commit_sha=row.commit_sha,
|
||||
created_at=row.created_at,
|
||||
updated_at=row.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _get_draft_or_404(db: Session, token: uuid.UUID) -> DocDraft:
|
||||
"""One draft by its URL credential; 404 when the token is unknown."""
|
||||
row = db.execute(select(DocDraft).where(DocDraft.token == token)).scalars().first()
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="draft not found")
|
||||
return row
|
||||
|
||||
|
||||
@router.post("", response_model=DocDraftOut, status_code=201)
|
||||
def create_draft(
|
||||
payload: DocDraftCreate,
|
||||
db: Session = Depends(get_db), # noqa: B008
|
||||
) -> DocDraftOut:
|
||||
"""Store one completed answer as a draft (201).
|
||||
|
||||
``title`` / ``body`` must be non-empty after strip (422 — the
|
||||
``SteeringNoteIn`` pattern; pydantic's ``min_length=1`` alone would
|
||||
let a whitespace-only value through). ``path`` passes
|
||||
:func:`validate_draft_path` (422 naming the violated rule).
|
||||
``token`` is a fresh ``uuid4`` — set on the PENDING row, so it ships
|
||||
in the same INSERT (the ``share_token`` precedent, phase 51);
|
||||
``status`` starts as ``draft``.
|
||||
"""
|
||||
title = payload.title.strip()
|
||||
if not title:
|
||||
raise HTTPException(status_code=422, detail="title must not be empty")
|
||||
body = payload.body.strip()
|
||||
if not body:
|
||||
raise HTTPException(status_code=422, detail="body must not be empty")
|
||||
path = validate_draft_path(payload.path)
|
||||
|
||||
row = DocDraft(
|
||||
token=uuid.uuid4(), # the URL credential — set before INSERT
|
||||
title=title,
|
||||
path=path,
|
||||
body=body,
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return _to_out(row)
|
||||
|
||||
|
||||
@router.get("/{token}", response_model=DocDraftOut)
|
||||
def get_draft(
|
||||
token: uuid.UUID,
|
||||
db: Session = Depends(get_db), # noqa: B008
|
||||
) -> DocDraftOut:
|
||||
"""One draft by its URL credential (the edit screen's load); 404
|
||||
``draft not found`` when the token is unknown (no enumeration — one
|
||||
message for every unknown token)."""
|
||||
return _to_out(_get_draft_or_404(db, token))
|
||||
|
||||
|
||||
@router.put("/{token}", response_model=DocDraftOut)
|
||||
def update_draft(
|
||||
token: uuid.UUID,
|
||||
payload: DocDraftUpdate,
|
||||
db: Session = Depends(get_db), # noqa: B008
|
||||
) -> DocDraftOut:
|
||||
"""Partial edit of a draft (the edit screen's Save); 404 when the
|
||||
token is unknown.
|
||||
|
||||
Each field is replaced only when supplied (absent keeps the row's
|
||||
current value; present must be non-empty after strip — 422). A
|
||||
supplied ``path`` re-runs :func:`validate_draft_path`; the same
|
||||
guard-rails apply on update as on create. ``updated_at`` is bumped
|
||||
on every PUT — via the model's ``onupdate=func.now()`` when a stored
|
||||
value changes, via an explicit raw ``UPDATE`` when the PUT is a
|
||||
no-op (empty body, or every supplied value identical — the ORM
|
||||
flushes nothing, so the onupdate default never fires).
|
||||
|
||||
Editing a ``pushed`` draft resets ``status`` to ``draft`` (phase 59
|
||||
D3 ASSUMPTION): the stored ``commit_sha`` no longer describes the
|
||||
current body, so the next push re-commits. The last push's
|
||||
``branch``/``commit_sha`` stay visible (the previous state) until
|
||||
the next push overwrites them.
|
||||
"""
|
||||
row = _get_draft_or_404(db, token)
|
||||
|
||||
# Validate everything BEFORE assigning anything, so a 422 on one
|
||||
# field can never leave a half-applied edit pending.
|
||||
new_title = payload.title.strip() if payload.title is not None else None
|
||||
if payload.title is not None and not new_title:
|
||||
raise HTTPException(status_code=422, detail="title must not be empty")
|
||||
new_path = validate_draft_path(payload.path) if payload.path is not None else None
|
||||
new_body = payload.body.strip() if payload.body is not None else None
|
||||
if payload.body is not None and not new_body:
|
||||
raise HTTPException(status_code=422, detail="body must not be empty")
|
||||
|
||||
changed = False
|
||||
if new_title is not None and row.title != new_title:
|
||||
row.title = new_title
|
||||
changed = True
|
||||
if new_path is not None and row.path != new_path:
|
||||
row.path = new_path
|
||||
changed = True
|
||||
if new_body is not None and row.body != new_body:
|
||||
row.body = new_body
|
||||
changed = True
|
||||
if row.status == "pushed":
|
||||
# D3 ASSUMPTION — see the docstring above.
|
||||
row.status = "draft"
|
||||
changed = True
|
||||
|
||||
if not changed:
|
||||
# No-op PUT: the onupdate default does not fire for a flush that
|
||||
# changed nothing, so bump updated_at explicitly (raw SQL — the
|
||||
# ORM object is already in its final state).
|
||||
db.execute(
|
||||
text("UPDATE doc_drafts SET updated_at = now() WHERE id = :id"),
|
||||
{"id": row.id},
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return _to_out(row)
|
||||
|
||||
|
||||
@router.post("/{token}/push", response_model=DocDraftPushed)
|
||||
def push_doc_draft(
|
||||
token: uuid.UUID,
|
||||
db: Session = Depends(get_db), # noqa: B008
|
||||
settings: Settings = Depends(get_settings), # noqa: B008
|
||||
) -> DocDraftPushed:
|
||||
"""Commit + push the draft's file (phase 59, task 04).
|
||||
|
||||
The single mutation the edit screen (task 06) triggers: take the
|
||||
draft's (title, path, body), commit + push through
|
||||
:func:`app.core.docs_push.push_document`, and record the outcome
|
||||
on the draft. Outcomes (checked in this order):
|
||||
|
||||
1. unknown token → 404 ``draft not found`` (same message as GET/
|
||||
PUT — no enumeration);
|
||||
2. ``settings.docs_configured`` false → 409 naming
|
||||
``BOR_DOCS_REPO`` (D3: the feature is inert by default — the
|
||||
optional-feature pattern of the git-sources env fallback);
|
||||
3. the stored ``path`` re-runs :func:`validate_draft_path` → 422
|
||||
on the first violated rule (a row must not be pushable into a
|
||||
bad path, whatever wrote it);
|
||||
4. :class:`DocsPushError` → 502 with ``detail=str(exc)`` — git's
|
||||
stderr, the ``GitSyncError`` → ``detail`` mapping from
|
||||
:mod:`app.api.git_sources`. Only a SUCCESS mutates the row:
|
||||
the failed push leaves ``status`` / ``branch`` / ``commit_sha``
|
||||
exactly as found (no partial commit).
|
||||
|
||||
On success the row becomes ``status = "pushed"`` with the landed
|
||||
``branch`` and ``commit_sha`` (must equal ``git rev-parse
|
||||
<branch>`` in the repo) and a bumped ``updated_at`` (the column's
|
||||
``onupdate`` fires — a successful push always rewrites at least
|
||||
``commit_sha``, so a flush always happens); the response is
|
||||
``DocDraftPushed(status="pushed", branch, commit_sha=sha)`` — the
|
||||
edit screen's branch + sha feedback (D3: no PR, no URL — the
|
||||
owner opens the PR themselves).
|
||||
"""
|
||||
row = _get_draft_or_404(db, token)
|
||||
if not settings.docs_configured:
|
||||
raise HTTPException(
|
||||
status_code=409, detail="docs repo not configured (BOR_DOCS_REPO)"
|
||||
)
|
||||
path = validate_draft_path(row.path)
|
||||
try:
|
||||
branch, sha = push_document(
|
||||
repo=settings.docs_repo,
|
||||
base_branch=settings.docs_base_branch,
|
||||
branch=settings.docs_branch,
|
||||
work_dir=settings.docs_work_dir,
|
||||
rel_path=path,
|
||||
content=row.body,
|
||||
commit_message=f"docs: {row.title}",
|
||||
)
|
||||
except DocsPushError as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from None
|
||||
row.status = "pushed"
|
||||
row.branch = branch
|
||||
row.commit_sha = sha
|
||||
db.commit()
|
||||
db.refresh(row)
|
||||
return DocDraftPushed(status="pushed", branch=branch, commit_sha=sha)
|
||||
+56
-1
@@ -9,7 +9,7 @@ import os
|
||||
import re
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic import field_validator
|
||||
from pydantic import ValidationInfo, field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
#: The built-in DEFAULT import formats (PLAN anchor A9, revised 2026-08-21;
|
||||
@@ -178,6 +178,30 @@ class Settings(BaseSettings):
|
||||
#: pattern).
|
||||
upload_max_mb: int = 512
|
||||
|
||||
# --- Docs push (phase 59: save a chat answer as documentation) ---
|
||||
#: The git repo a saved chat answer is committed to (phase 59, D3):
|
||||
#: **any** remote — a URL (``https://``, ``ssh://``, ``git@``) or a
|
||||
#: local path (generic git remote — no ``gh``, no GitHub assumption).
|
||||
#: While empty the feature is inert: the "Save as doc" action is
|
||||
#: hidden and the push endpoint 409s (the optional-feature pattern of
|
||||
#: the git-sources env fallback).
|
||||
docs_repo: str = ""
|
||||
#: The branch pushes land on (phase 59): each push cuts it fresh from
|
||||
#: ``docs_base_branch`` and ``git push --ff-only``s it — the owner
|
||||
#: opens the PR themselves (D3: no PR tooling). A git branch token,
|
||||
#: so no whitespace and no ``..`` (the validator below —
|
||||
#: all-or-nothing with ``docs_repo``).
|
||||
docs_branch: str = "bor-docs"
|
||||
#: The branch each push bases off (fetched/reset before the
|
||||
#: ``checkout -B`` of ``docs_branch``). Same token shape rules as
|
||||
#: ``docs_branch``.
|
||||
docs_base_branch: str = "main"
|
||||
#: Where ``docs_repo`` is checked out on the server. Raw string —
|
||||
#: ``Path.expanduser()`` is applied by the push service, not here
|
||||
#: (the ``sources_dir``/``upload_dir`` convention). Deliberately kept
|
||||
#: separate from ``sources_dir`` (the source checkouts).
|
||||
docs_work_dir: str = "~/bor-docs"
|
||||
|
||||
@field_validator("import_extensions")
|
||||
@classmethod
|
||||
def _import_extensions_known(cls, v: str) -> str:
|
||||
@@ -215,6 +239,28 @@ class Settings(BaseSettings):
|
||||
raise ValueError("upload_max_mb must be > 0 (MiB)")
|
||||
return v
|
||||
|
||||
@field_validator("docs_branch", "docs_base_branch")
|
||||
@classmethod
|
||||
def _docs_branch_tokens(cls, v: str, info: ValidationInfo) -> str:
|
||||
"""Git branch-token shape guard (phase 59, D3) — all-or-nothing:
|
||||
while ``docs_repo`` is empty the feature is inert, so the
|
||||
(ignored) branch values must not block startup; once a repo IS
|
||||
set, a blank / whitespace-bearing / ``..``-bearing branch is a
|
||||
typo that would corrupt a ``git checkout`` argument, so it fails
|
||||
loudly at startup (the ``agent_max_rounds`` pattern), naming the
|
||||
field."""
|
||||
repo = info.data.get("docs_repo")
|
||||
if not isinstance(repo, str) or not repo.strip():
|
||||
return v
|
||||
name = info.field_name or "docs branch"
|
||||
if not v.strip():
|
||||
raise ValueError(f"{name} must not be empty while docs_repo is set")
|
||||
if re.search(r"\s", v):
|
||||
raise ValueError(f"{name} must not contain whitespace (a git branch token)")
|
||||
if ".." in v:
|
||||
raise ValueError(f"{name} must not contain '..' (a git branch token)")
|
||||
return v
|
||||
|
||||
# Suggested questions (onboarding + empty state).
|
||||
suggestions: list[str] = [
|
||||
"How is my Kubernetes cluster set up?",
|
||||
@@ -242,6 +288,15 @@ class Settings(BaseSettings):
|
||||
"""
|
||||
return [part.strip() for part in self.git_sources.split(",") if part.strip()]
|
||||
|
||||
@property
|
||||
def docs_configured(self) -> bool:
|
||||
"""True while a docs repo is configured (phase 59): the "Save as
|
||||
doc" surface is live. Empty (or whitespace-only) ``docs_repo``
|
||||
→ the feature is inert — no button for anyone, the push
|
||||
endpoint 409s (the optional-feature pattern of the git-sources
|
||||
env fallback)."""
|
||||
return bool(self.docs_repo.strip())
|
||||
|
||||
@property
|
||||
def effective_api_key(self) -> str:
|
||||
"""API key for aipi: explicit setting, then $AIPI_KEY, then a placeholder."""
|
||||
|
||||
@@ -147,6 +147,8 @@ HTML_PAGES: tuple[str, ...] = (
|
||||
# dynamic /shared/<token> — both must carry the no-cache + ?v=
|
||||
# contract, so the direct URL can never pin stale assets).
|
||||
"/shared.html",
|
||||
# phase 59: the doc edit screen (the flow page task 06 ships).
|
||||
"/doc-edit.html",
|
||||
)
|
||||
|
||||
#: Prefix of the versioned static assets (header-only caching; the body is
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Docs-push service (phase 59, task 03).
|
||||
|
||||
Commits a document into the configured docs repo and pushes it to the
|
||||
configured branch. **D3 (owner-locked, 2026-08-31): push only** — the
|
||||
flow ends at the push to the branch named in ``.env``
|
||||
(``BOR_DOCS_REPO`` + ``BOR_DOCS_BRANCH``); no PR is ever created or
|
||||
attempted (no ``gh``, no PR URL, no URL construction, no
|
||||
GitHub-specific logic). The remote is **generic** — a local path,
|
||||
``https://``, ``ssh://``, any host — and git is invoked exclusively
|
||||
through :func:`scripts.git_sync.run_git` (A11: stdlib ``subprocess``
|
||||
only, no new packages): :func:`push_document` is the only caller of
|
||||
git for this feature besides :mod:`scripts.git_sync` itself.
|
||||
|
||||
The push is a plain ``git push origin <branch>`` — git 2.55 no longer
|
||||
accepts the ``--ff-only`` flag on ``push`` — and a push without
|
||||
``-f`` / a ``+`` refspec already refuses non-fast-forward updates
|
||||
(client-side, against the remote's live refs), so a
|
||||
concurrently-advanced remote fails loudly: never a force-push, never
|
||||
a merge. Every failure raises :class:`DocsPushError` carrying git's
|
||||
stderr (the ``GitSyncError`` style), and the checkout under
|
||||
``work_dir`` is left as-is for inspection.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from scripts.git_sync import GitSyncError, run_git
|
||||
|
||||
__all__ = ["DocsPushError", "push_document"]
|
||||
|
||||
# Fixed per-invocation commit identity (phase 59 ASSUMPTION) — passed
|
||||
# with ``-c`` on the commit itself, so the push never relies on the
|
||||
# machine's global git config. ``commit.gpgsign=false`` keeps a
|
||||
# headless server from ever prompting for a GPG pinentry (this repo's
|
||||
# own no-GPG-signing policy).
|
||||
_COMMIT_NAME = "Brain of Reese"
|
||||
_COMMIT_EMAIL = "bor@local"
|
||||
|
||||
|
||||
class DocsPushError(RuntimeError):
|
||||
"""A docs push failed (or git is missing); carries git's stderr."""
|
||||
|
||||
|
||||
def _refuse_unsafe_rel_path(rel_path: str) -> None:
|
||||
"""Defensively re-assert the upstream path guard-rails (task 02).
|
||||
|
||||
The file is written inside the docs checkout, so a blank path, an
|
||||
absolute path, or any ``.``/``..`` component is refused here as
|
||||
well — even though the drafts API already guard-railed the value.
|
||||
"""
|
||||
rel = Path(rel_path)
|
||||
if not rel_path.strip() or rel.is_absolute() or any(part in (".", "..") for part in rel.parts):
|
||||
raise DocsPushError(f"refusing unsafe rel_path: {rel_path!r}")
|
||||
|
||||
|
||||
def push_document(
|
||||
repo: str,
|
||||
base_branch: str,
|
||||
branch: str,
|
||||
work_dir: str,
|
||||
rel_path: str,
|
||||
content: str,
|
||||
commit_message: str,
|
||||
) -> tuple[str, str]:
|
||||
"""Commit *content* at *rel_path* and push (ff-only) to *branch*.
|
||||
|
||||
Step sequence (each step via :func:`scripts.git_sync.run_git`;
|
||||
``cwd=work_dir`` once the checkout exists):
|
||||
|
||||
1. Ensure the checkout: without ``.git`` →
|
||||
``git clone --depth 1 --branch <base_branch> <repo> <work_dir>``
|
||||
(the base branch is explicit — the remote's default may differ).
|
||||
2. ``git fetch --depth 1 origin <base_branch>`` — re-sync the base
|
||||
before every push. The fetched base tip (``FETCH_HEAD``) is
|
||||
captured immediately — a later *failed* fetch clears
|
||||
``FETCH_HEAD``.
|
||||
3. ``git fetch --depth 100 origin <branch>`` — **failure is
|
||||
expected** while the branch does not exist on the remote yet
|
||||
(swallowed and continued); on success the fetched branch tip is
|
||||
captured the same way.
|
||||
4. Attach the local branch:
|
||||
|
||||
- a local branch left by a previous push → ``git checkout
|
||||
<branch>`` — keep its own history, so a
|
||||
concurrently-advanced remote then fails the push loudly in
|
||||
step 7 instead of being silently re-based or merged;
|
||||
- otherwise ``git checkout -B <branch>`` onto the captured
|
||||
branch tip (re-attach onto the previously pushed branch — its
|
||||
history, so the push can fast-forward) or, on first push,
|
||||
onto the captured base tip (a new branch from the fresh
|
||||
base). The captured shas are used instead of symbolic refs:
|
||||
git's local transport (a local-path remote) does not create
|
||||
``refs/remotes/origin/<branch>`` for a newly fetched branch,
|
||||
and a failed fetch clears ``FETCH_HEAD``.
|
||||
5. Write the file (parent dirs created;
|
||||
:func:`_refuse_unsafe_rel_path` re-asserts the parts check
|
||||
defensively).
|
||||
6. ``git add -- <rel_path>`` + ``commit -m <commit_message>`` with
|
||||
the fixed per-invocation identity (``-c user.name`` /
|
||||
``-c user.email`` — no reliance on global git config).
|
||||
7. ``git push origin <branch>`` — creates the remote branch on
|
||||
first push; a concurrently-advanced remote is refused
|
||||
non-fast-forward (never a force-push, never a merge).
|
||||
8. ``sha = git rev-parse HEAD``; return ``(branch, sha)``.
|
||||
|
||||
Raises :class:`DocsPushError` (git's stderr in the message) on any
|
||||
failure — the checkout is left as-is for inspection.
|
||||
"""
|
||||
_refuse_unsafe_rel_path(rel_path)
|
||||
work = Path(work_dir).expanduser()
|
||||
try:
|
||||
if not (work / ".git").exists():
|
||||
work.parent.mkdir(parents=True, exist_ok=True)
|
||||
run_git(
|
||||
[
|
||||
"git",
|
||||
"clone",
|
||||
"--depth",
|
||||
"1",
|
||||
"--branch",
|
||||
base_branch,
|
||||
repo,
|
||||
str(work),
|
||||
],
|
||||
cwd=work.parent,
|
||||
)
|
||||
run_git(["git", "fetch", "--depth", "1", "origin", base_branch], cwd=work)
|
||||
# Capture the fetched base tip now — a *failed* fetch clears
|
||||
# FETCH_HEAD, so the symbolic ref cannot be reused later.
|
||||
base_sha = run_git(["git", "rev-parse", "FETCH_HEAD"], cwd=work).strip()
|
||||
try:
|
||||
run_git(["git", "fetch", "--depth", "100", "origin", branch], cwd=work)
|
||||
branch_sha = run_git(["git", "rev-parse", "FETCH_HEAD"], cwd=work).strip()
|
||||
except GitSyncError:
|
||||
branch_sha = None # the branch does not exist on the remote yet
|
||||
if run_git(["git", "branch", "--list", branch], cwd=work).strip():
|
||||
run_git(["git", "checkout", branch], cwd=work)
|
||||
else:
|
||||
anchor = branch_sha if branch_sha is not None else base_sha
|
||||
run_git(["git", "checkout", "-B", branch, anchor], cwd=work)
|
||||
target = work / rel_path
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(content, encoding="utf-8")
|
||||
run_git(["git", "add", "--", rel_path], cwd=work)
|
||||
run_git(
|
||||
[
|
||||
"git",
|
||||
"-c",
|
||||
f"user.name={_COMMIT_NAME}",
|
||||
"-c",
|
||||
f"user.email={_COMMIT_EMAIL}",
|
||||
"-c",
|
||||
"commit.gpgsign=false",
|
||||
"commit",
|
||||
"-m",
|
||||
commit_message,
|
||||
],
|
||||
cwd=work,
|
||||
)
|
||||
run_git(["git", "push", "origin", branch], cwd=work)
|
||||
sha = run_git(["git", "rev-parse", "HEAD"], cwd=work).strip()
|
||||
except GitSyncError as err:
|
||||
raise DocsPushError(str(err)) from err
|
||||
return branch, sha
|
||||
@@ -31,6 +31,7 @@ from app.api.chats import (
|
||||
shared_page_router as chats_shared_page_router,
|
||||
)
|
||||
from app.api.config import router as config_router
|
||||
from app.api.doc_drafts import router as doc_drafts_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
|
||||
@@ -81,6 +82,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(steering_router, prefix="/api")
|
||||
app.include_router(sync_router, prefix="/api")
|
||||
app.include_router(chats_router, prefix="/api")
|
||||
app.include_router(doc_drafts_router, prefix="/api")
|
||||
# Phase 51: the anonymous shared-chat read — NO admin dependency.
|
||||
# /api/shared/<token> is the JSON snapshot; /shared/<token> (the
|
||||
# page route below, registered without a prefix) is the page.
|
||||
|
||||
@@ -31,6 +31,16 @@ Data model — see ``.agent/PLAN.md`` §Data Model:
|
||||
generation of the knowledge base is current,
|
||||
bumped exactly once per KB-changing sync so saved
|
||||
chats can be marked stale (phase 53).
|
||||
* ``doc_drafts`` — server-side drafts of chat answers saved as
|
||||
documentation: one row per "Save as doc" action
|
||||
(the long answer body lives here, never in a URL),
|
||||
keyed by an unguessable ``uuid4`` ``token`` (the
|
||||
edit screen's URL credential — the share-token
|
||||
trust model, phase 51); ``status`` moves
|
||||
``draft`` → ``pushed`` (``branch`` +
|
||||
``commit_sha`` recorded) when the push endpoint
|
||||
commits + pushes the file to the
|
||||
``BOR_DOCS_REPO`` branch (phase 59).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -205,6 +215,62 @@ class GitSource(Base):
|
||||
added_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
|
||||
class DocDraft(Base):
|
||||
"""One server-side draft of a chat answer saved as documentation
|
||||
(phase 59, task 01).
|
||||
|
||||
A long answer body must live on the **server**, never in a URL: the
|
||||
"Save as doc" action POSTs the answer's raw markdown to
|
||||
``POST /api/doc-drafts`` (task 02), which stores it here and hands
|
||||
back an unguessable 128-bit ``uuid4`` ``token`` — the edit
|
||||
screen's URL credential (``/doc-edit.html?draft=<token>``, the
|
||||
share-token trust model, phase 51). ``status`` stays ``draft``
|
||||
until the push endpoint (task 04) commits + pushes the file to the
|
||||
``BOR_DOCS_REPO`` branch — then it is ``pushed``, with ``branch``
|
||||
and ``commit_sha`` recorded (the UI's branch + sha feedback; D3:
|
||||
no PR tooling — the owner opens the PR themselves).
|
||||
"""
|
||||
|
||||
__tablename__ = "doc_drafts"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
#: The URL credential (``/doc-edit.html?draft=<token>``): an
|
||||
#: unguessable 128-bit ``uuid4`` — never the row id, never
|
||||
#: sequential/guessable. Unique NOT NULL: unlike the NULLable
|
||||
#: ``saved_chats.share_token`` there is no "un-drafted" state, so
|
||||
#: NULLs never occur (always set on create).
|
||||
token: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), unique=True, nullable=False, default=uuid.uuid4
|
||||
)
|
||||
#: The document's title. Defaults client-side to the last user
|
||||
#: question (whitespace-collapsed, ≤120 chars — the chat auto-title
|
||||
#: convention, phase 50); the edit screen changes anything.
|
||||
title: Mapped[str] = mapped_column(Text)
|
||||
#: The in-repo file path (default ``docs/<slug>.md``). Guard-railled
|
||||
#: by the API layer (task 02 — repo-relative, no ``..``); the
|
||||
#: column itself is plain TEXT (the ``documents.path`` precedent).
|
||||
path: Mapped[str] = mapped_column(Text)
|
||||
#: The markdown body — the answer's raw text (never HTML — the
|
||||
#: ``bor.chat.v1`` record's ``text``), edited on the edit screen.
|
||||
body: Mapped[str] = mapped_column(Text)
|
||||
#: "draft" until the push endpoint commits + pushes the file, then
|
||||
#: "pushed" — the domain is enforced by the API layer (the
|
||||
#: ``git_sources.kind`` phase-38 precedent: plain TEXT + server
|
||||
#: default, no CHECK constraint).
|
||||
status: Mapped[str] = mapped_column(Text, default="draft", server_default="'draft'")
|
||||
#: Set on push (task 04): the branch the commit landed on (the
|
||||
#: ``BOR_DOCS_BRANCH`` name); NULL while still a draft.
|
||||
branch: Mapped[str | None] = mapped_column(Text)
|
||||
#: ... and the pushed branch's new HEAD sha (must equal
|
||||
#: ``git rev-parse <branch>`` in the repo); NULL while still a
|
||||
#: draft.
|
||||
commit_sha: Mapped[str | None] = mapped_column(Text)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
|
||||
class SavedChat(Base):
|
||||
"""One owner-saved chat conversation (phase 50).
|
||||
|
||||
|
||||
@@ -523,3 +523,72 @@ class UnshareOut(BaseModel):
|
||||
|
||||
chat_id: uuid.UUID
|
||||
shared: bool
|
||||
|
||||
|
||||
class DocDraftCreate(BaseModel):
|
||||
"""``POST /api/doc-drafts`` body (phase 59, task 02): one completed
|
||||
chat answer about to become documentation.
|
||||
|
||||
``title`` arrives client-side as the last user question
|
||||
(whitespace-collapsed, ≤120 chars — the chat auto-title convention,
|
||||
phase 50); ``path`` as ``docs/<slug>.md``; ``body`` is the answer's
|
||||
raw markdown (never HTML — the ``bor.chat.v1`` record's ``text``,
|
||||
the phase-50/51 round-trip convention). The path guard-rails (task
|
||||
02 — repo-relative, no ``..``, no absolute path) run in the API
|
||||
layer so the 422 details stay fixed strings; the max lengths mirror
|
||||
the ``documents`` table (title 500, path 1000).
|
||||
"""
|
||||
|
||||
title: str = Field(min_length=1, max_length=500)
|
||||
path: str = Field(min_length=1, max_length=1000)
|
||||
body: str = Field(min_length=1)
|
||||
|
||||
|
||||
class DocDraftUpdate(BaseModel):
|
||||
"""``PUT /api/doc-drafts/{token}`` body (phase 59, task 02): a
|
||||
partial update — each field is replaced only when supplied (absent
|
||||
keeps the row's current value; present must be non-empty — the
|
||||
``SavedChatUpdate`` optional-title pattern, extended to all three
|
||||
editable fields). The same path guard-rails as create run in the
|
||||
API layer when ``path`` is supplied.
|
||||
"""
|
||||
|
||||
title: str | None = Field(default=None, min_length=1, max_length=500)
|
||||
path: str | None = Field(default=None, min_length=1, max_length=1000)
|
||||
body: str | None = Field(default=None, min_length=1)
|
||||
|
||||
|
||||
class DocDraft(BaseModel):
|
||||
"""One draft row, full payload (create/get/put response, phase 59).
|
||||
|
||||
``token`` is the URL credential (``/doc-edit.html?draft=<token>``
|
||||
— the unguessable ``uuid4``, the share-token trust model, phase
|
||||
51). ``status`` is ``draft`` until the push endpoint commits +
|
||||
pushes the file, then ``pushed`` with ``branch`` / ``commit_sha``
|
||||
recorded (both NULL while still a draft). Datetimes serialize
|
||||
ISO-8601 on the wire (pydantic default).
|
||||
"""
|
||||
|
||||
token: uuid.UUID
|
||||
title: str
|
||||
path: str
|
||||
body: str
|
||||
status: str
|
||||
branch: str | None = None
|
||||
commit_sha: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class DocDraftPushed(BaseModel):
|
||||
"""``POST /api/doc-drafts/{token}/push`` success response (phase 59,
|
||||
task 04): the commit + ``git push --ff-only`` landed — ``branch``
|
||||
is the ``BOR_DOCS_BRANCH`` name and ``commit_sha`` the pushed
|
||||
branch's new HEAD (the edit screen's branch + sha feedback; it must
|
||||
equal ``git rev-parse <branch>`` in the repo — the E2E source of
|
||||
truth is the bare repo's state, not the UI alone).
|
||||
"""
|
||||
|
||||
status: Literal["pushed"] = "pushed"
|
||||
branch: str
|
||||
commit_sha: str
|
||||
|
||||
+148
-1
@@ -495,6 +495,126 @@ function markLastRetryable() {
|
||||
const prev = lastIdx > 0 ? conversation[lastIdx - 1] : null;
|
||||
if (!prev || prev.who !== "user") return;
|
||||
appendRetryButton(lastBrainWrap);
|
||||
// Phase 59: "Save as doc" stays the meta row's rightmost action —
|
||||
// when the Retry button lands on the SAME bubble, re-append the save
|
||||
// button after it (the auto margins split the free space between the
|
||||
// right-aligned buttons; DOM order decides the right edge).
|
||||
const saveDocBtn = lastBrainWrap.querySelector(".save-as-doc-btn");
|
||||
if (saveDocBtn && saveDocBtn.parentElement)
|
||||
saveDocBtn.parentElement.appendChild(saveDocBtn);
|
||||
}
|
||||
|
||||
/* Phase 59 (owner-locked 2026-08-31, TODO.md L3): the bottom-right
|
||||
* "Save as doc" action of EVERY completed brain bubble (deflected
|
||||
* included — same scope as Tune; a stopped partial is a note, not an
|
||||
* answer, so m.stopped records never get it — the restore call site
|
||||
* gates on it). Gate: admin (the whoami gate Tune uses) AND a
|
||||
* configured docs repo (docsRepoConfigured — /api/config, settled in
|
||||
* the boot IIFE before any bubble renders). `markdown` is the RAW
|
||||
* persisted answer text — m.text on the restore path, the
|
||||
* done/fallback raw text on the live path — NEVER the rendered HTML.
|
||||
* The .save-as-doc-btn's margin-inline-start: auto pushes it to the
|
||||
* row's right edge (the TODO's "bottom right"); markLastRetryable
|
||||
* keeps it rightmost when the last bubble also carries the Retry
|
||||
* button.
|
||||
*
|
||||
* Click: default title (the LAST user question, whitespace-collapsed,
|
||||
* ≤120 chars — the phase-50 auto-title convention) + default in-repo
|
||||
* path (docs/<slug>.md) → POST /api/doc-drafts {title, path, body} →
|
||||
* 201 → /doc-edit.html?draft=<token> (the edit screen, task 06, owns
|
||||
* the rest). Failure → the neutral one-line banner (phase-55
|
||||
* convention), the conversation unblocked, no navigation. */
|
||||
const SAVE_AS_DOC_ICON =
|
||||
'<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 3H6a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V8z"/><path d="M14 3v5h5"/><path d="M9 13h6M9 16h4"/></svg>';
|
||||
|
||||
const DOC_TITLE_MAX = 120; // the phase-50 auto-title cap (owner-locked)
|
||||
|
||||
/* The default doc title: the LAST user question's text,
|
||||
* whitespace-collapsed, truncated to 120 chars — the phase-50
|
||||
* auto-title convention (server-side: " ".join(text.split())[:120])
|
||||
* applied to the last question. Defensive "Note" when the
|
||||
* conversation has no user record (the UI cannot produce one).
|
||||
* " ".join(split()) == replace(/\s+/g, " ").trim() for non-empty
|
||||
* input; the trim keeps the leading/trailing-whitespace edge identical. */
|
||||
function defaultDocTitle() {
|
||||
let question = "";
|
||||
for (let i = conversation.length - 1; i >= 0; i -= 1) {
|
||||
if (conversation[i].who === "user") {
|
||||
question = conversation[i].text;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return question.replace(/\s+/g, " ").trim().slice(0, DOC_TITLE_MAX) || "Note";
|
||||
}
|
||||
|
||||
/* The default in-repo path slug (phase 59 locked assumption):
|
||||
* lowercase, runs of non-alphanumerics → "-", trimmed, ≤60 chars,
|
||||
* empty → "note". The 60-cut can land mid dash-run — the trailing
|
||||
* trim again keeps the path from ending in a dangling "-". */
|
||||
function docSlug(title) {
|
||||
const slug = title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 60)
|
||||
.replace(/-+$/g, "");
|
||||
return slug || "note";
|
||||
}
|
||||
|
||||
/* The bottom-right "Save as doc" button — the appendTuneButton
|
||||
* pattern: reuses the .msg-meta row when it exists (role=list → the
|
||||
* button joins as a listitem so ARIA stays valid), otherwise creates
|
||||
* a plain meta row; one button per bubble. */
|
||||
function appendSaveAsDocButton(wrap, markdown) {
|
||||
if (!isAdmin || !docsRepoConfigured) return; // phase 59: admin + configured
|
||||
const body = wrap.querySelector(".msg-body");
|
||||
if (!body) return;
|
||||
let meta = body.querySelector(".msg-meta");
|
||||
if (!meta) {
|
||||
meta = document.createElement("div");
|
||||
meta.className = "msg-meta";
|
||||
body.appendChild(meta);
|
||||
}
|
||||
if (meta.querySelector(".save-as-doc-btn")) return; // one per bubble
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "save-as-doc-btn"; // margin-inline-start: auto → bottom-right
|
||||
if (meta.getAttribute("role") === "list") btn.setAttribute("role", "listitem");
|
||||
btn.innerHTML = SAVE_AS_DOC_ICON + "<span>Save as doc</span>";
|
||||
btn.addEventListener("click", () => saveAsDoc(btn, markdown));
|
||||
meta.appendChild(btn);
|
||||
}
|
||||
|
||||
/* Create the draft from the bubble's RAW markdown and hand off to the
|
||||
* edit screen. Double-click guard: one save at a time (the button is
|
||||
* disabled until the outcome — released in the finally, never stale,
|
||||
* PLAN §7.4). */
|
||||
async function saveAsDoc(btn, markdown) {
|
||||
if (btn.disabled) return; // one save at a time (double-click guard)
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const title = defaultDocTitle();
|
||||
const path = `docs/${docSlug(title)}.md`;
|
||||
const res = await fetch("/api/doc-drafts", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title, path, body: markdown }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
// Neutral one-line copy (phase-55 convention) — the detail may
|
||||
// be a guard-rail 422 or a server hiccup; neither is actionable
|
||||
// here, and the conversation stays unblocked (no navigation).
|
||||
showErrorBanner("Couldn't save the answer as a doc — try again.");
|
||||
return;
|
||||
}
|
||||
const draft = await res.json();
|
||||
// 201: the draft's uuid4 token IS the edit screen's credential.
|
||||
location.assign("/doc-edit.html?draft=" + draft.token);
|
||||
} catch {
|
||||
showErrorBanner("Couldn't save the answer as a doc — is the app reachable?");
|
||||
} finally {
|
||||
btn.disabled = false; // released on EVERY outcome — never stale
|
||||
}
|
||||
}
|
||||
|
||||
/* Inline tuning form under the bubble: labeled textarea (maxlength 2000)
|
||||
@@ -1091,6 +1211,10 @@ function renderStoredMessage(m) {
|
||||
}
|
||||
appendSources(wrap, m.sources);
|
||||
appendTuneButton(wrap); // restored brain answers are tunable too
|
||||
// Phase 59: the RAW persisted markdown (m.text — HTML is never
|
||||
// persisted). A stopped partial (m.stopped) is a note, not an answer
|
||||
// — no button (the live stop path adds none either).
|
||||
if (!m.stopped) appendSaveAsDocButton(wrap, m.text);
|
||||
if (m.stopped) appendStoppedNote(wrap); // phase 48: the stop marker restores
|
||||
lastBrainWrap = wrap; // phase 49: the LAST restored brain bubble wins
|
||||
}
|
||||
@@ -1538,6 +1662,16 @@ const signInLink = document.querySelector("#sign-in-link");
|
||||
const signOutBtn = document.querySelector("#sign-out-btn");
|
||||
let isAdmin = false;
|
||||
|
||||
/* Phase 59 (owner-locked 2026-08-31, TODO.md L3): the docs-push gate
|
||||
* — GET /api/config's ``docs_repo_configured`` (settings.docs_configured
|
||||
* server-side), surfaced by brand.js as window.BOR_DOCS_REPO_CONFIGURED
|
||||
* (the way app_name is: a window global, false until the boot fetch
|
||||
* proves otherwise). Captured ONCE in the boot IIFE after the fetch
|
||||
* settles, so the "Save as doc" buttons render exactly once: present
|
||||
* for a configured admin, absent for everyone else — and while
|
||||
* BOR_DOCS_REPO is empty the feature is inert (D3). */
|
||||
let docsRepoConfigured = false;
|
||||
|
||||
function applyAuthState() {
|
||||
if (signInLink) signInLink.hidden = isAdmin;
|
||||
if (signOutBtn) signOutBtn.hidden = !isAdmin;
|
||||
@@ -1812,11 +1946,15 @@ async function runTurn(text, { reask = false } = {}) {
|
||||
appendMaybeTry(wrap, ev.suggestions);
|
||||
}
|
||||
appendSources(wrap, ev.sources);
|
||||
appendTuneButton(wrap); // every completed brain bubble is tunable
|
||||
// Thinking-without-answer (reasoning can exhaust max_tokens): the
|
||||
// bubble gets the empty-answer fallback — what the user saw is
|
||||
// what gets persisted.
|
||||
const finalText = acc || (sawThinking ? EMPTY_ANSWER_FALLBACK : "");
|
||||
appendTuneButton(wrap); // every completed brain bubble is tunable
|
||||
// Phase 59: the RAW persisted markdown (never the rendered
|
||||
// HTML) — exactly the string rememberBrainTurn stores below,
|
||||
// so a reload (the restore path) offers the identical draft.
|
||||
appendSaveAsDocButton(wrap, finalText || acc || "…");
|
||||
if (!acc && sawThinking) {
|
||||
wrap.querySelector(".bubble").innerHTML = renderMarkdown(finalText);
|
||||
}
|
||||
@@ -1851,6 +1989,7 @@ async function runTurn(text, { reask = false } = {}) {
|
||||
const fallback = EMPTY_ANSWER_FALLBACK;
|
||||
const fwrap = addMessage("brain", fallback);
|
||||
appendTuneButton(fwrap);
|
||||
appendSaveAsDocButton(fwrap, fallback); // phase 59: parity with the done path
|
||||
rememberBrainTurn(fallback, {}); // persist what the user actually saw
|
||||
lastBrainWrap = fwrap;
|
||||
markLastRetryable(); // phase 49: the fallback bubble is retryable too
|
||||
@@ -1965,6 +2104,14 @@ window.addEventListener("pagehide", () => {
|
||||
(async () => {
|
||||
await initSharedHeader(); // header.js: whoami + Sign in/out + steering gate
|
||||
isAdmin = await fetchIsAdmin(); // the same cached promise — one whoami
|
||||
// Phase 59: /api/config is settled BEFORE any bubble renders —
|
||||
// brand.js's single boot fetch (window.BOR_CONFIG_PROMISE, never
|
||||
// rejecting) has set window.BOR_DOCS_REPO_CONFIGURED (false until
|
||||
// proven), so a restored conversation of a configured admin gets the
|
||||
// "Save as doc" button exactly once: no flash, no re-render, no
|
||||
// second fetch (the brand fetch IS the config fetch).
|
||||
await (window.BOR_CONFIG_PROMISE ?? Promise.resolve());
|
||||
docsRepoConfigured = window.BOR_DOCS_REPO_CONFIGURED === true;
|
||||
applyAuthState(); // chat page: the auth pair (idempotent with header.js)
|
||||
// Phase 55 (task 03): no Share-reveal step — the pill is static,
|
||||
// always-visible markup (visible to every visitor, phase 51 contract).
|
||||
|
||||
+43
-11
@@ -9,7 +9,15 @@
|
||||
* Contract (phase 39 locked decisions — A11 no CDN, runtime fetch):
|
||||
* • window.BOR_BRAND = "Brain of Reese" synchronously — the default
|
||||
* name renders immediately, no blank flash;
|
||||
* • fetch("/api/config", { cache: "no-store" }) — on success with a
|
||||
* • Phase 59: window.BOR_DOCS_REPO_CONFIGURED = false synchronously
|
||||
* (inert until proven) and window.BOR_CONFIG_PROMISE — the SAME
|
||||
* fetch's promise, exposed at parse time so the chat page's boot
|
||||
* (app.js) can await it BEFORE rendering any bubble; the "Save as
|
||||
* doc" gating flag is then final, and a restored conversation of a
|
||||
* configured admin never misses (or flashes) the button. The
|
||||
* promise NEVER rejects — the error arm warns and resolves null;
|
||||
* • fetch("/api/config", { cache: "no-store" }) — on success the
|
||||
* docs flag is set from cfg.docs_repo_configured, and on a
|
||||
* non-empty app_name, window.BOR_BRAND is updated and the name is
|
||||
* applied to the DOM:
|
||||
* 1. document.title — global replace of the literal;
|
||||
@@ -36,6 +44,12 @@
|
||||
reading window.BOR_BRAND at evaluation time always find a value. */
|
||||
window.BOR_BRAND = "Brain of Reese";
|
||||
|
||||
/* Phase 59 (owner-locked 2026-08-31, TODO.md L3): the docs-push flag —
|
||||
surfaced the way app_name is (a window global, inert until the boot
|
||||
fetch proves otherwise). false = the "Save as doc" action is hidden
|
||||
for everyone (BOR_DOCS_REPO empty — the feature is off). */
|
||||
window.BOR_DOCS_REPO_CONFIGURED = false;
|
||||
|
||||
/* The literal the DOM passes replace — the default name. The page
|
||||
scripts' own `window.BOR_BRAND || "Brain of Reese"` fallbacks stay in
|
||||
sync with it. */
|
||||
@@ -50,10 +64,31 @@ function escapeHTML(s) {
|
||||
}[c]));
|
||||
}
|
||||
|
||||
function applyBrand() {
|
||||
fetch("/api/config", { cache: "no-store" })
|
||||
/* The /api/config fetch — started at TOP LEVEL (parse time) so
|
||||
window.BOR_CONFIG_PROMISE exists before the page's module scripts
|
||||
evaluate (app.js's boot awaits it, above). Phase 59: the flag lands
|
||||
here, the moment the answer arrives — before any DOM pass. The
|
||||
promise NEVER rejects: the error arm warns (the loadHealth house
|
||||
style — the page never breaks) and resolves to null, so the default
|
||||
name + false flag stand. */
|
||||
const BOR_CONFIG_PROMISE = fetch("/api/config", { cache: "no-store" })
|
||||
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
|
||||
.then((cfg) => {
|
||||
.then(
|
||||
(cfg) => {
|
||||
window.BOR_DOCS_REPO_CONFIGURED = cfg?.docs_repo_configured === true;
|
||||
return cfg;
|
||||
},
|
||||
(err) => {
|
||||
// Fetch failure (or a non-JSON body): the default name stays —
|
||||
// the page never breaks (the loadHealth house style).
|
||||
console.warn("brand: /api/config did not answer — keeping the default name.", err);
|
||||
return null;
|
||||
},
|
||||
);
|
||||
window.BOR_CONFIG_PROMISE = BOR_CONFIG_PROMISE;
|
||||
|
||||
function applyBrand() {
|
||||
BOR_CONFIG_PROMISE.then((cfg) => {
|
||||
const name = typeof cfg?.app_name === "string" ? cfg.app_name.trim() : "";
|
||||
if (!name) return; // empty / missing: the default stands
|
||||
window.BOR_BRAND = name;
|
||||
@@ -117,16 +152,13 @@ function applyBrand() {
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
// Fetch failure (or a non-JSON body): the default name stays —
|
||||
// the page never breaks (the loadHealth house style).
|
||||
console.warn("brand: /api/config did not answer — keeping the default name.", err);
|
||||
});
|
||||
}
|
||||
|
||||
/* The top level only sets the global (synchronously, at parse time);
|
||||
the DOM passes run once the document is ready. */
|
||||
/* The DOM passes run once the document is ready AND the config is
|
||||
settled (applyBrand awaits the parse-time promise) — the fetch may
|
||||
resolve before or after DOMContentLoaded; both orderings apply the
|
||||
brand exactly once. */
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", applyBrand);
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
/* Brain of Reese — doc edit screen (phase 59, task 06).
|
||||
*
|
||||
* The standalone, admin-gated flow page
|
||||
* ``/doc-edit.html?draft=<token>``: load the draft the chat page's
|
||||
* "Save as doc" action (task 05) just created, edit the three fields
|
||||
* (title, in-repo path, markdown body), and push — the commit lands on
|
||||
* the .env-configured docs branch of the .env-configured repo, and the
|
||||
* owner opens the PR themselves (D3: no PR tooling anywhere).
|
||||
*
|
||||
* The page is static; the API is the authority. The whoami gate is the
|
||||
* ``sources-gate`` pattern (phases 16/35/50): anonymous visitors see
|
||||
* the sign-in gate and NO /api/doc-drafts call is made (the draft
|
||||
* endpoints are admin-only regardless — no draft data can leak through
|
||||
* the page).
|
||||
*
|
||||
* Boot (admin): read ``?draft=<token>`` — missing → the error banner
|
||||
* "No draft specified."; a malformed (non-uuid) token is treated as
|
||||
* unknown → "Draft not found." with NO fetch (the shared.js malformed-
|
||||
* token precedent) — then ``GET /api/doc-drafts/<token>``: 404 →
|
||||
* "Draft not found.", any other non-2xx → the server's detail line, a
|
||||
* network failure → the fixed one-line copy. On 200 the three fields
|
||||
* are filled with VALUES (``.value`` only — never as markup; the body
|
||||
* is user-derived markdown).
|
||||
*
|
||||
* Push (the §7.4 never-stale lifecycle, on #push-doc-btn):
|
||||
* 1. client-side sanity FIRST — non-empty title/body, no ".." in the
|
||||
* path (the server re-runs its guard-rails and is the authority;
|
||||
* the browser's native ``required`` is the first line, these the
|
||||
* second);
|
||||
* 2. the button disables + relabels "Pushing…" and the live region
|
||||
* says "Pushing…";
|
||||
* 3. ``PUT /api/doc-drafts/<token>`` with all three fields — the
|
||||
* push endpoint (task 04) commits the ROW's title/path/body, so
|
||||
* the current edits must land on the row first (an unsaved edit
|
||||
* would otherwise push the stale text);
|
||||
* 4. ``POST /api/doc-drafts/<token>/push``:
|
||||
* • 200 → the live region: `Pushed to <branch> — commit <sha7>.`
|
||||
* (the full sha comes from the API, the first seven chars are
|
||||
* shown — a re-push after further edits is a NEW commit on the
|
||||
* same branch, the D3 ASSUMPTION); the button re-enables with
|
||||
* its idle label;
|
||||
* • non-2xx → the #push-error banner with the API's detail — for
|
||||
* a git failure (502) that is git's stderr, trimmed to its
|
||||
* first meaningful lines; the fields are PRESERVED (the fix is
|
||||
* an edit, not a re-type) and the button re-enables;
|
||||
* • network failure → the fixed one-line copy, same recovery.
|
||||
*
|
||||
* 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). The slim flow
|
||||
* page carries no nav / auth pair / steering panel, so initSharedHeader
|
||||
* would settle nothing — the import exists for the CACHED whoami
|
||||
* (fetchIsAdmin) the gate runs on and for the Containerfile stage-1
|
||||
* contract (every page module imports ./header.js).
|
||||
*/
|
||||
|
||||
import { fetchIsAdmin } from "./header.js";
|
||||
|
||||
/* ---------- page elements (doc-edit.html, task 06) ---------- */
|
||||
const gateEl = document.querySelector("#doc-edit-gate");
|
||||
const contentEl = document.querySelector("#doc-edit-content");
|
||||
const formEl = document.querySelector("#doc-edit-form");
|
||||
const titleInput = document.querySelector("#draft-title");
|
||||
const pathInput = document.querySelector("#draft-path");
|
||||
const bodyInput = document.querySelector("#draft-body");
|
||||
const pushBtn = document.querySelector("#push-doc-btn");
|
||||
const statusEl = document.querySelector("#push-status");
|
||||
const errorEl = document.querySelector("#push-error");
|
||||
|
||||
/* The button's idle label (restored in the finally — never stale). */
|
||||
const IDLE_LABEL = "Push to docs branch";
|
||||
|
||||
/* The URL credential's shape — a uuid4 token (task 05 navigated with
|
||||
it). A non-uuid value is unknown, full stop: "Draft not found." with
|
||||
no fetch (the shared.js malformed-token precedent — a 422
|
||||
validation line would be framework noise, not a house message). */
|
||||
const UUID_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/* Set by boot once whoami says admin and the token is present — the
|
||||
push handler refuses to run without it (the form is unusable in
|
||||
that state anyway: "No draft specified." is on the banner). */
|
||||
let draftToken = null;
|
||||
|
||||
/* ---------- feedback channels (§7.4 never stale) ---------- */
|
||||
|
||||
/* The polite live region (role="status"): the push lifecycle line —
|
||||
textContent only (the branch/sha are server data). */
|
||||
function setStatus(message) {
|
||||
if (statusEl) statusEl.textContent = message;
|
||||
}
|
||||
|
||||
/* The error banner (role="alert"): shown with a message, hidden on a
|
||||
fresh attempt. */
|
||||
function showError(message) {
|
||||
if (errorEl) {
|
||||
errorEl.textContent = message;
|
||||
errorEl.hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
function clearError() {
|
||||
if (errorEl) errorEl.hidden = true;
|
||||
}
|
||||
|
||||
/* FastAPI error bodies: a string detail or the validation-error array
|
||||
(the first entry's msg is the human line). Same extraction as
|
||||
git-sources.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;
|
||||
}
|
||||
|
||||
/* Git's stderr, trimmed to its first meaningful lines (task 06):
|
||||
blank lines and the "hint:" chatter are dropped, at most three
|
||||
lines are kept — the banner stays one compact line, and a
|
||||
single-line detail (409 unconfigured, 422 guard-rail) passes
|
||||
through untouched. */
|
||||
function trimGitDetail(detail) {
|
||||
const lines = String(detail)
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l && !l.startsWith("hint:"));
|
||||
return lines.slice(0, 3).join(" ") || "The push failed.";
|
||||
}
|
||||
|
||||
/* ---------- load (GET /api/doc-drafts/<token>) ----------
|
||||
* The 200 body fills the three fields — VALUES only (input.value /
|
||||
* textarea.value), never as markup: the body is user-derived markdown
|
||||
* and the title/path may contain anything but markup. */
|
||||
async function loadDraft(token) {
|
||||
let r;
|
||||
try {
|
||||
r = await fetch(`/api/doc-drafts/${token}`);
|
||||
} catch {
|
||||
showError("Could not reach the server — is the app running?");
|
||||
return;
|
||||
}
|
||||
if (r.status === 404) {
|
||||
showError("Draft not found.");
|
||||
return;
|
||||
}
|
||||
if (!r.ok) {
|
||||
showError(await apiDetail(r, `The server could not load the draft (${r.status}).`));
|
||||
return;
|
||||
}
|
||||
let draft;
|
||||
try {
|
||||
draft = await r.json();
|
||||
} catch {
|
||||
showError("The server sent an unreadable draft — try again.");
|
||||
return;
|
||||
}
|
||||
if (titleInput) titleInput.value = draft.title;
|
||||
if (pathInput) pathInput.value = draft.path;
|
||||
if (bodyInput) bodyInput.value = draft.body;
|
||||
}
|
||||
|
||||
/* ---------- push (PUT the edits, then POST /push) ----------
|
||||
* The push endpoint commits the ROW's title/path/body, so the current
|
||||
* field values are PUT first (all three — a partial PUT would keep a
|
||||
* stale field) and the push runs only once that lands. Every failure
|
||||
* path lands the error banner (the server's detail line — git's
|
||||
* stderr, trimmed, for 502s) and re-enables the button in the
|
||||
* finally: never stale, success OR failure. */
|
||||
function wirePush() {
|
||||
if (!formEl || !pushBtn) return;
|
||||
formEl.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
if (!draftToken) {
|
||||
showError("No draft specified.");
|
||||
return;
|
||||
}
|
||||
// Client-side sanity (the server is the authority — it re-runs the
|
||||
// guard-rails): non-empty title/body, no ".." in the path. The
|
||||
// browser's native `required` is the first line, these the second
|
||||
// (whitespace-only values included).
|
||||
const title = titleInput.value.trim();
|
||||
const path = pathInput.value.trim();
|
||||
const body = bodyInput.value.trim();
|
||||
if (!title) {
|
||||
showError("Enter a title for the doc.");
|
||||
titleInput.focus();
|
||||
return;
|
||||
}
|
||||
if (!body) {
|
||||
showError("The doc body must not be empty.");
|
||||
bodyInput.focus();
|
||||
return;
|
||||
}
|
||||
if (path.includes("..")) {
|
||||
showError("The path must not contain '..'.");
|
||||
pathInput.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
clearError(); // a new attempt starts clean
|
||||
setStatus("Pushing…");
|
||||
pushBtn.disabled = true; // §7.4: one push per click
|
||||
pushBtn.textContent = "Pushing…";
|
||||
try {
|
||||
const put = await fetch(`/api/doc-drafts/${draftToken}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title, path, body }),
|
||||
});
|
||||
if (!put.ok) {
|
||||
// 404 (the row vanished) / 422 (a field no longer passes the
|
||||
// guard-rails) — the server line, the edits preserved, and
|
||||
// any stale success line cleared (one claim at a time).
|
||||
setStatus("");
|
||||
showError(await apiDetail(put, "Could not save the doc edits — try again."));
|
||||
return;
|
||||
}
|
||||
const r = await fetch(`/api/doc-drafts/${draftToken}/push`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (!r.ok) {
|
||||
// 409 (repo unconfigured), 422 (the stored path), 502 (git's
|
||||
// stderr) — the detail is the actionable line, trimmed to its
|
||||
// first meaningful lines; the fields are preserved and the
|
||||
// stale success line (if any) is cleared.
|
||||
setStatus("");
|
||||
showError(trimGitDetail(await apiDetail(r, "The push failed — try again.")));
|
||||
return;
|
||||
}
|
||||
const pushed = await r.json();
|
||||
// The full sha comes from the API; the first seven chars are the
|
||||
// display value (a re-push after further edits is a NEW commit —
|
||||
// the D3 ASSUMPTION — so the button re-enables for it).
|
||||
setStatus(
|
||||
`Pushed to ${pushed.branch} — commit ${String(pushed.commit_sha).slice(0, 7)}.`,
|
||||
);
|
||||
} catch {
|
||||
setStatus("");
|
||||
showError("Could not reach the server — is the app running?");
|
||||
} finally {
|
||||
pushBtn.disabled = false; // never stale — success OR failure
|
||||
pushBtn.textContent = IDLE_LABEL;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
wirePush();
|
||||
|
||||
/* ---------- boot ----------
|
||||
* The admin gate FIRST (the sources-gate pattern — one cached
|
||||
* whoami): anonymous visitors get the gate and NO /api/doc-drafts
|
||||
* call (the endpoints are admin-only regardless — no draft data
|
||||
* leaks through the page). The admin gets the form, then the draft
|
||||
* load from ?draft=<token>. */
|
||||
(async () => {
|
||||
const admin = await fetchIsAdmin();
|
||||
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;
|
||||
|
||||
// The URL credential (task 05's navigation: the 201 token).
|
||||
const token = new URLSearchParams(window.location.search).get("draft");
|
||||
if (!token) {
|
||||
showError("No draft specified.");
|
||||
return;
|
||||
}
|
||||
if (!UUID_RE.test(token)) {
|
||||
// A non-uuid token is unknown — no fetch (the shared.js
|
||||
// malformed-token precedent: a 422 validation line is framework
|
||||
// noise, not a house message).
|
||||
showError("Draft not found.");
|
||||
return;
|
||||
}
|
||||
draftToken = token;
|
||||
await loadDraft(token);
|
||||
if (titleInput) titleInput.focus(); // land the caret in the first field
|
||||
})();
|
||||
@@ -734,6 +734,40 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
.retry-btn svg { width: 14px; height: 14px; display: block; }
|
||||
.retry-btn:hover { background: var(--brand-soft); color: var(--ink); }
|
||||
|
||||
/* Phase 59 (owner-locked 2026-08-31, TODO.md L3): the "Save as doc"
|
||||
button — the bottom-right action of every completed brain bubble's
|
||||
meta row (the JS gates: admin + a configured docs repo; this rule
|
||||
only styles). The exact visual family of .tune-btn / .retry-btn
|
||||
(same pill size/spacing, the global :focus-visible ring, >=44px via
|
||||
min-height) so the meta actions read as one set — the brand hover
|
||||
pair like Tune (a docs action), the file glyph rides currentColor.
|
||||
margin-inline-start: auto pushes it to the row's RIGHT edge (the
|
||||
TODO's "bottom right"; markLastRetryable keeps it rightmost when
|
||||
the last bubble also carries the Retry button). Contrast:
|
||||
ink-soft on --bg ~8.6:1, hover brand-ink on --brand-soft — AA,
|
||||
same as the family. */
|
||||
.save-as-doc-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
min-height: 44px;
|
||||
margin-inline-start: auto;
|
||||
padding: 0.35rem 0.8rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
background: transparent;
|
||||
color: var(--ink-soft);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 0.82rem;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.save-as-doc-btn svg { width: 14px; height: 14px; display: block; }
|
||||
.save-as-doc-btn:hover { background: var(--brand-soft); color: var(--brand-ink); }
|
||||
.save-as-doc-btn:disabled { opacity: 0.6; cursor: wait; } /* draft POST in flight */
|
||||
|
||||
/* Phase 48: the "Stopped" note in a stopped brain bubble's meta row:
|
||||
ink-soft on the surface bubble ≈6.9:1, the 10px filled-square glyph
|
||||
centered with the row (the Tune button shares the row), and
|
||||
@@ -2699,6 +2733,163 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
|
||||
.doc-modal-backdrop { transition: none; }
|
||||
}
|
||||
|
||||
/* ---------- Doc edit screen (phase 59, task 06) ----------
|
||||
/doc-edit.html: the admin-gated edit screen for a doc draft (title,
|
||||
in-repo path, markdown body) — a FLOW page, not one of the app's
|
||||
pages, so the header is SLIM (brand + "← Back to chat" only). The
|
||||
46rem base column is HARD-CODED: a form column, not a reading
|
||||
column — it does not ride --chat-column, so phase 58's wide-desktop
|
||||
doubling never stretches the form. The .sources-gate gate is reused
|
||||
verbatim (phases 16/35/50). Every pair reuses the Phase-08 AA
|
||||
palette; touch targets >=44px; :focus-visible via the global 3px
|
||||
outline rule. No CDN, system fonts. */
|
||||
.doc-edit-shell {
|
||||
width: 100%;
|
||||
max-width: 46rem; /* the 46rem base column (hard-coded — see above) */
|
||||
margin-inline: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* The slim header's back link — the .doc-back ghost language
|
||||
(document.html): >=44px target, --line border, ink-soft (5.1:1 on
|
||||
the --surface bar) rising to ink on hover; pushed to the bar's right
|
||||
edge by the header-inner flex (margin-left: auto — the nav's
|
||||
own pattern). */
|
||||
.doc-edit-back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
min-height: 44px;
|
||||
margin-left: auto;
|
||||
padding: 0.4rem 0.9rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--ink-soft);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.doc-edit-back:hover { color: var(--ink); border-color: var(--ink-soft); }
|
||||
.doc-edit-back svg { width: 16px; height: 16px; display: block; }
|
||||
|
||||
/* The edit form — the tuning form's card as a vertical stack: labeled
|
||||
title input, mono path input, the mono markdown textarea (min-height
|
||||
20rem — the body is the star), and the actions row (the brand Push
|
||||
button + the back link). Inset fields (bg fill on the surface card). */
|
||||
#doc-edit-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.9rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 1.5rem 1.5rem 1.75rem;
|
||||
}
|
||||
#doc-edit-form label {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
#draft-title,
|
||||
#draft-path {
|
||||
width: 100%;
|
||||
font: inherit;
|
||||
font-size: 1rem;
|
||||
color: var(--ink);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.55rem 0.75rem;
|
||||
min-height: 44px;
|
||||
}
|
||||
/* The in-repo path is machine data — mono (the git-sources URL-input
|
||||
convention). */
|
||||
#draft-path {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
/* The markdown body: mono, tall (min-height 20rem), vertical resize.
|
||||
ink on bg = 16.7:1. */
|
||||
#draft-body {
|
||||
width: 100%;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.5;
|
||||
color: var(--ink);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.75rem 0.9rem;
|
||||
min-height: 20rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
/* Actions row: the primary Push button (brand, dark ink on brand
|
||||
5.2:1 — never white on brand) + the back link; wraps at narrow
|
||||
widths. */
|
||||
.doc-edit-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
#push-doc-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 44px;
|
||||
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;
|
||||
padding-inline: 1.25rem;
|
||||
}
|
||||
#push-doc-btn:hover:not(:disabled) { background: #7d88f5; }
|
||||
#push-doc-btn:disabled { opacity: 0.6; cursor: wait; }
|
||||
|
||||
/* The success status line (role=status): the ok family (ok-ink on
|
||||
ok-bg 10.6:1) when a push outcome has landed — min-height holds the
|
||||
line's space so the layout never jumps when the text lands. Empty
|
||||
(before the first push, or after a failure cleared the stale line)
|
||||
it is the dashed placeholder (the #archive-upload-result language). */
|
||||
.doc-edit-status {
|
||||
margin: 0;
|
||||
min-height: 1.5rem;
|
||||
background: var(--ok-bg);
|
||||
color: var(--ok-ink);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.45rem 0.8rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.doc-edit-status:empty {
|
||||
background: transparent;
|
||||
border-style: dashed;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
/* The error banner (role=alert): the err family (err-ink on err-bg
|
||||
9.3:1, err-line border) — git's stderr may carry long paths, so
|
||||
long words break instead of overflowing the card. */
|
||||
.doc-edit-error {
|
||||
margin: 0;
|
||||
background: var(--err-bg);
|
||||
color: var(--err-ink);
|
||||
border: 1px solid var(--err-line);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0.5rem 0.8rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* ---------- Footer ---------- */
|
||||
.app-footer {
|
||||
border-top: 1px solid var(--line);
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
<!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="Edit a saved chat answer before it is committed to the docs repository (admin-only).">
|
||||
<title>Edit doc · 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%231a0f0f%22%20stroke=%22%23f43f5e%22%20stroke-width=%224%22%20stroke-linejoin=%22round%22/%3E%3Ccircle%20cx=%2232%22%20cy=%2232%22%20r=%226.5%22%20fill=%22%23f43f5e%22/%3E%3Cpath%20d=%22M32%2025.5V16M32%2048v-9.5M25.5%2032H16M48%2032h-9.5%22%20stroke=%22%23fca5a5%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 59 task 06: the SLIM header — this is a flow page (the
|
||||
login.html / shared.html minimal-flow-page lineage), not one of
|
||||
the app's pages: no nav, no auth pair, no hamburger. Brand +
|
||||
the "← Back to chat" link are the whole chrome. -->
|
||||
<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="#1a0f0f" stroke="#f43f5e" stroke-width="4" stroke-linejoin="round"/><circle cx="32" cy="32" r="6.5" fill="#f43f5e"/><path d="M32 25.5V16M32 48v-9.5M25.5 32H16M48 32h-9.5" stroke="#fca5a5" stroke-width="3" stroke-linecap="round"/></svg>
|
||||
<span class="brand-text">Brain of <strong>Reese</strong></span>
|
||||
</span>
|
||||
<a class="doc-edit-back" href="/">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5"/><path d="m12 19-7-7 7-7"/></svg>
|
||||
<span>Back to chat</span>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="main" class="app-main" tabindex="-1">
|
||||
<div class="container doc-edit-shell">
|
||||
<!-- The 46rem base column (a FORM column — it hard-codes 46rem,
|
||||
it does not ride --chat-column, so phase 58's wide-desktop
|
||||
doubling never stretches the form). -->
|
||||
<div class="page-head">
|
||||
<h1>Edit doc</h1>
|
||||
<p class="page-sub">
|
||||
Review the saved answer, adjust anything, then push it to the
|
||||
docs branch — the commit lands in the configured docs repo;
|
||||
you open the PR yourself.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Phase 59 task 06: the admin gate — the EXACT #sources-gate
|
||||
pattern (phase 16) and the same .sources-gate visual
|
||||
language (phases 35/50). The page is static; the API is the
|
||||
authority — the draft endpoints are admin-only regardless,
|
||||
so a non-admin visitor gets the gate and NO draft data
|
||||
(doc-edit.js makes no /api/doc-drafts call before whoami
|
||||
says admin). -->
|
||||
<section class="sources-gate" id="doc-edit-gate" aria-labelledby="doc-edit-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="doc-edit-gate-title">Sign in to edit docs</h2>
|
||||
<p class="sources-gate-sub">
|
||||
Saving a chat answer as documentation is admin-only. Chat —
|
||||
and any document an answer cites — stays open to everyone.
|
||||
</p>
|
||||
<a class="sources-gate-link" href="/login.html?next=/doc-edit.html">Sign in</a>
|
||||
</section>
|
||||
|
||||
<!-- SHIPS hidden (anonymous-safe; the gate is what anonymous
|
||||
visitors see). doc-edit.js reveals it once the cached whoami
|
||||
says admin, then loads the draft from ?draft=<token> (the
|
||||
uuid4 token task 05's button navigated with). -->
|
||||
<div id="doc-edit-content" hidden>
|
||||
<form id="doc-edit-form">
|
||||
<label for="draft-title">Title</label>
|
||||
<input
|
||||
id="draft-title"
|
||||
name="title"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
required
|
||||
>
|
||||
|
||||
<label for="draft-path">In-repo path</label>
|
||||
<input
|
||||
id="draft-path"
|
||||
name="path"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
required
|
||||
>
|
||||
|
||||
<label for="draft-body">Body — markdown</label>
|
||||
<textarea id="draft-body" name="body" required></textarea>
|
||||
|
||||
<div class="doc-edit-actions">
|
||||
<button type="submit" id="push-doc-btn">Push to docs branch</button>
|
||||
<a class="doc-edit-back" href="/">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5"/><path d="m12 19-7-7 7-7"/></svg>
|
||||
<span>Back to chat</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- §7.4 never-stale: the polite live region carries the
|
||||
push lifecycle — "Pushing…" while the request is out,
|
||||
then `Pushed to <branch> — commit <sha7>.` on success.
|
||||
doc-edit.js owns the text (textContent only). -->
|
||||
<p class="doc-edit-status" id="push-status" role="status" aria-live="polite"></p>
|
||||
|
||||
<!-- The error banner (role=alert), hidden until a load or
|
||||
push failure: the server's detail (git's stderr,
|
||||
trimmed to its first meaningful lines) lands here and
|
||||
the fields are preserved — the fix is an edit, not a
|
||||
re-type. -->
|
||||
<div class="doc-edit-error" id="push-error" role="alert" hidden></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="app-footer">
|
||||
<div class="container footer-inner">
|
||||
<span>Powered by Reese's self-hosted models</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Phase 39: the brand layer — classic script, first on the page:
|
||||
window.BOR_BRAND at parse time, refreshed from /api/config.
|
||||
Phase 59 task 06: the page module loads the shared header module
|
||||
through its own relative `import "./header.js"` — a hoisted
|
||||
import evaluated before this body runs (single-evaluation
|
||||
design: no direct header.js <script> tag; esbuild inlines it
|
||||
into the page bundle in the image build). On this slim flow
|
||||
page the import is the cached whoami (fetchIsAdmin) the admin
|
||||
gate runs on. -->
|
||||
<script src="assets/brand.js"></script>
|
||||
<script type="module" src="/assets/doc-edit.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+16
-6
@@ -10,14 +10,16 @@ No credentials are stored here; whatever the URL/SSH config supplies is
|
||||
used.
|
||||
|
||||
This module is the only place the ``git`` CLI is invoked (A11: stdlib
|
||||
``subprocess`` only, no new packages).
|
||||
``subprocess`` only, no new packages) — every git command goes through
|
||||
:func:`run_git`: the clone/pull in :func:`clone_or_pull` and the
|
||||
docs-push sequence in :mod:`app.core.docs_push` (phase 59).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
__all__ = ["GitSyncError", "clone_or_pull"]
|
||||
__all__ = ["GitSyncError", "clone_or_pull", "run_git"]
|
||||
|
||||
|
||||
class GitSyncError(RuntimeError):
|
||||
@@ -40,14 +42,22 @@ def clone_or_pull(url: str, dest: Path | str) -> Path:
|
||||
dest = Path(dest)
|
||||
if not dest.exists() or not (dest / ".git").exists():
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
_run(["git", "clone", "--depth", "1", url, str(dest)], cwd=dest.parent)
|
||||
run_git(["git", "clone", "--depth", "1", url, str(dest)], cwd=dest.parent)
|
||||
else:
|
||||
_run(["git", "pull", "--ff-only"], cwd=dest)
|
||||
run_git(["git", "pull", "--ff-only"], cwd=dest)
|
||||
return dest
|
||||
|
||||
|
||||
def _run(argv: list[str], cwd: Path) -> str:
|
||||
"""Run a git command, capturing output; raise GitSyncError on failure."""
|
||||
def run_git(argv: list[str], cwd: Path) -> str:
|
||||
"""Run one git command, capturing output; raise GitSyncError on failure.
|
||||
|
||||
The single ``git`` invocation point for the whole app (A11). Every
|
||||
step of :func:`clone_or_pull` and of the docs-push sequence
|
||||
(:mod:`app.core.docs_push`, phase 59) goes through here, so error
|
||||
handling stays uniform: captured stdout on success, and
|
||||
:class:`GitSyncError` carrying git's stderr on a non-zero exit (or
|
||||
when the git binary is missing from PATH).
|
||||
"""
|
||||
try:
|
||||
proc = subprocess.run(argv, cwd=cwd, capture_output=True, text=True)
|
||||
except FileNotFoundError:
|
||||
|
||||
@@ -116,9 +116,9 @@ def test_html_pages_are_no_cache_and_versioned(page: Page, app_url: str) -> None
|
||||
|
||||
|
||||
def test_other_pages_share_the_token(page: Page, app_url: str) -> None:
|
||||
"""/sources.html, /login.html and /history.html (phase 50): each
|
||||
document revalidates, and all three pages' stylesheet requests carry
|
||||
the same process token."""
|
||||
"""/sources.html, /login.html, /history.html (phase 50) and
|
||||
/doc-edit.html (phase 59): each document revalidates, and all four
|
||||
pages' stylesheet requests carry the same process token."""
|
||||
token = _expected_token()
|
||||
assert token
|
||||
|
||||
@@ -134,7 +134,8 @@ def test_other_pages_share_the_token(page: Page, app_url: str) -> None:
|
||||
sources_token = navigate("/sources.html")
|
||||
login_token = navigate("/login.html")
|
||||
history_token = navigate("/history.html") # phase 50: the new page
|
||||
assert sources_token == login_token == history_token == token
|
||||
docedit_token = navigate("/doc-edit.html") # phase 59: the doc edit screen
|
||||
assert sources_token == login_token == history_token == docedit_token == token
|
||||
|
||||
|
||||
def test_shared_page_is_no_cache_and_versioned(
|
||||
|
||||
@@ -144,8 +144,12 @@ def test_api_config_serves_both_names(testy_server: str, app_server: str) -> Non
|
||||
r = httpx.get(f"{TESTY_URL}/api/config", timeout=5)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert set(body) == {"app_name", "version"}
|
||||
# Phase 59 (task 05): the third key is the docs-push flag — the
|
||||
# "Save as doc" gating; both instances run with BOR_DOCS_REPO
|
||||
# empty, so it is the inert false here.
|
||||
assert set(body) == {"app_name", "version", "docs_repo_configured"}
|
||||
assert body["app_name"] == TESTY_NAME
|
||||
assert body["docs_repo_configured"] is False
|
||||
|
||||
# The shared conftest instance keeps the default (the other
|
||||
# suites' title/label contract rides on it).
|
||||
|
||||
@@ -0,0 +1,602 @@
|
||||
"""Phase 59 story E2E (Playwright): the save → edit → push loop, with
|
||||
the BARE REPO as source of truth.
|
||||
|
||||
Story: n/a (TODO-derived — "Convert response to documentation that gets
|
||||
committed back to a repo specified in .env … allows you to modify the
|
||||
new documentation before [pushing] to the specified repo").
|
||||
Run in isolation (DB must be up: ``podman compose up -d db``; ``git``
|
||||
on PATH — the suite skips without it):
|
||||
|
||||
uv run pytest tests/e2e/test_response_to_docs.py -v --no-cov
|
||||
|
||||
The loop under test: a completed brain bubble carries a bottom-right
|
||||
"Save as doc" action (admin + a configured ``BOR_DOCS_REPO``) → it
|
||||
opens ``/doc-edit.html?draft=<token>`` prefilled (auto-title from the
|
||||
last question, path ``docs/<slug>.md``, body = the answer's MARKDOWN
|
||||
SOURCE — never the rendered HTML) → Push commits + pushes to the
|
||||
``.env``-configured branch of the ``.env``-configured repo. Every
|
||||
success assertion reads the **bare repo itself** (``git show
|
||||
<branch>:<path>``, ``git rev-list``, ``git rev-parse``) — the UI text
|
||||
is only the entry point (D3: no PR is ever created or attempted — the
|
||||
flow ends at the push to the branch).
|
||||
|
||||
App boots (the conftest pattern, module-scoped — as in
|
||||
``test_git_sources_admin.py``):
|
||||
|
||||
* the module app boots with ``BOR_DOCS_REPO=<tmp>/docs.git`` (a local
|
||||
BARE repo seeded with one commit on ``main``), ``BOR_DOCS_BRANCH=
|
||||
bor-docs``, ``BOR_DOCS_BASE_BRANCH=main``, ``BOR_DOCS_WORK_DIR=
|
||||
<tmp>/docs-work``;
|
||||
* ``test_unconfigured_hides_button`` boots a SECOND app (separate
|
||||
fixture, ``APP_PORT + 1``) with NO docs env — the inert default:
|
||||
no button for anyone, draft creation still allowed (drafts are
|
||||
repo-independent), push 409s naming ``BOR_DOCS_REPO``.
|
||||
|
||||
The mock LLM keeps every answer byte-deterministic: the suite replays
|
||||
the same question through ``POST /api/chat`` (raw SSE, the
|
||||
``test_chat_rag.py`` pattern) to recover the exact markdown source the
|
||||
draft must carry — so "body == the answer's markdown source" is an
|
||||
exact-byte assertion, not a contains check.
|
||||
|
||||
Test → story mapping (Playwright Mapping Rule):
|
||||
1. ``test_save_edit_push``
|
||||
2. ``test_second_push_fast_forwards``
|
||||
3. ``test_guest_has_no_button``
|
||||
4. ``test_unconfigured_hides_button``
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from playwright.sync_api import Page, expect
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.config import Settings
|
||||
from app.db import SessionLocal
|
||||
from app.rag.importer import ImportSummary, import_sources
|
||||
from app.rag.llm import LLMClient
|
||||
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]
|
||||
FIXTURES = REPO / "tests" / "fixtures" / "docs"
|
||||
|
||||
APP_URL = f"http://127.0.0.1:{APP_PORT}"
|
||||
#: The unconfigured app's port (task 07: a second app boot WITHOUT
|
||||
#: ``BOR_DOCS_REPO`` — a separate fixture on the next port, so it can
|
||||
#: run alongside the module app).
|
||||
UNCONF_URL = f"http://127.0.0.1:{APP_PORT + 1}"
|
||||
|
||||
BRANCH = "bor-docs"
|
||||
BASE_BRANCH = "main"
|
||||
|
||||
#: On-topic fixture questions (the house phrasing — proven HIGH gate in
|
||||
#: test_chat_rag.py / test_pinned_composer.py, so every turn renders a
|
||||
#: grounded answer with the deterministic marker, never a deflection).
|
||||
QUESTION_1 = "How is my Kubernetes cluster set up?"
|
||||
QUESTION_2 = "What is in the new-service deployment?"
|
||||
MOCK_ANSWER_MARKER = "Deterministic mock answer for E2E"
|
||||
|
||||
#: The distinctive line test 1 appends to the body before pushing —
|
||||
#: ASCII on purpose (git's output must match byte-for-byte), and a
|
||||
#: module constant so test 2 can reconstruct file 1's expected content
|
||||
#: (deterministic: the mock answer + this exact suffix).
|
||||
E2E_MARKER = "E2E-DOCS-MARKER (appended by the response-to-docs story suite)"
|
||||
|
||||
#: The edit screen's URL shape (task 05 navigates with the uuid4 token).
|
||||
DRAFT_URL_RE = re.compile(r"/doc-edit\.html\?draft=[0-9a-f-]{36}")
|
||||
#: The success line (task 06): `Pushed to <branch> — commit <sha7>.`
|
||||
SUCCESS_SHA_RE = re.compile(r"commit ([0-9a-f]{7})\.$")
|
||||
|
||||
|
||||
def _git_available() -> bool:
|
||||
try:
|
||||
return subprocess.run(
|
||||
["git", "--version"], capture_output=True, timeout=10
|
||||
).returncode == 0
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not _git_available(), reason="git is not on PATH (the docs push is real git)"
|
||||
)
|
||||
|
||||
|
||||
def _git(args: list[str], cwd: Path | None = None) -> str:
|
||||
"""One git command (the bare repo is the source of truth); fail loud."""
|
||||
proc = subprocess.run(
|
||||
["git", *args], cwd=cwd, capture_output=True, text=True, timeout=60
|
||||
)
|
||||
assert proc.returncode == 0, f"git {' '.join(args)} failed: {proc.stderr}"
|
||||
return proc.stdout
|
||||
|
||||
|
||||
def doc_slug(title: str) -> str:
|
||||
"""The app.js slug rule (phase 59 locked assumption), ported:
|
||||
lowercase, runs of non-alphanumerics → ``-``, trimmed, ≤60 chars,
|
||||
empty → ``note`` (the trailing trim survives a mid-dash 60-cut)."""
|
||||
slug = (
|
||||
re.sub(r"[^a-z0-9]+", "-", title.lower())
|
||||
.strip("-")[:60]
|
||||
.rstrip("-")
|
||||
)
|
||||
return slug or "note"
|
||||
|
||||
|
||||
def _admin_cookies(page: Page) -> dict[str, str]:
|
||||
"""The signed session cookies the browser holds after a form login
|
||||
— used to call the admin API with plain httpx (the
|
||||
``test_cache_busting.py`` pattern)."""
|
||||
return {
|
||||
c["name"]: c["value"]
|
||||
for c in page.context.cookies()
|
||||
if "name" in c and "value" in c
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def docs_repo(tmp_path_factory: pytest.TempPathFactory) -> SimpleNamespace:
|
||||
"""The local BARE docs repo (the .env remote, D3-generic): one
|
||||
seed commit (``README.md``) pushed as ``main``. ``work`` is where
|
||||
the app's ``BOR_DOCS_WORK_DIR`` checkout lands (it persists for the
|
||||
whole module — the second push exercises the existing-checkout
|
||||
path)."""
|
||||
base = tmp_path_factory.mktemp("docs-git")
|
||||
bare = base / "docs.git"
|
||||
_git(["init", "--bare", str(bare)])
|
||||
seed = base / "seed"
|
||||
_git(["init", "-b", "main", str(seed)])
|
||||
(seed / "README.md").write_text("# e2e docs repo\n", encoding="utf-8")
|
||||
_git(["add", "--", "README.md"], cwd=seed)
|
||||
# -c identity + no GPG signing: the machine's global git config
|
||||
# (gpgsign=true here) must not leak into the fixture.
|
||||
_git(
|
||||
[
|
||||
"-c", "user.name=E2E Seeder",
|
||||
"-c", "user.email=e2e@local",
|
||||
"-c", "commit.gpgsign=false",
|
||||
"commit", "-m", "seed: README",
|
||||
],
|
||||
cwd=seed,
|
||||
)
|
||||
_git(["remote", "add", "origin", str(bare)], cwd=seed)
|
||||
_git(["push", "origin", "main"], cwd=seed)
|
||||
return SimpleNamespace(bare=bare, work=base / "docs-work")
|
||||
|
||||
|
||||
def _spawn_app(port: int, mock_port: int, docs_env: dict[str, str] | None) -> subprocess.Popen:
|
||||
"""One uvicorn boot (the conftest app_server env shape); ``None``
|
||||
docs_env = NO docs variables at all (the unconfigured app)."""
|
||||
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_port}/v1"
|
||||
)
|
||||
# Mock-calibrated threshold (conftest pattern): the fixture questions
|
||||
# gate HIGH, so every turn is a grounded answer with the marker.
|
||||
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
|
||||
if docs_env is None:
|
||||
for var in (
|
||||
"BOR_DOCS_REPO",
|
||||
"BOR_DOCS_BRANCH",
|
||||
"BOR_DOCS_BASE_BRANCH",
|
||||
"BOR_DOCS_WORK_DIR",
|
||||
):
|
||||
env.pop(var, None)
|
||||
else:
|
||||
env.update(docs_env)
|
||||
return subprocess.Popen(
|
||||
[sys.executable, "-m", "uvicorn", "app.main:app",
|
||||
"--host", "127.0.0.1", "--port", str(port), "--log-level", "warning"],
|
||||
cwd=REPO,
|
||||
env=env,
|
||||
)
|
||||
|
||||
|
||||
def _stop(proc: subprocess.Popen) -> None:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app_server(mock_llm: int, docs_repo: SimpleNamespace) -> Iterator[str]:
|
||||
"""The configured app under test (module scope — shadows the
|
||||
conftest session app; an isolated run never starts two)."""
|
||||
proc = _spawn_app(
|
||||
APP_PORT,
|
||||
mock_llm,
|
||||
{
|
||||
"BOR_DOCS_REPO": str(docs_repo.bare),
|
||||
"BOR_DOCS_BRANCH": BRANCH,
|
||||
"BOR_DOCS_BASE_BRANCH": BASE_BRANCH,
|
||||
"BOR_DOCS_WORK_DIR": str(docs_repo.work),
|
||||
},
|
||||
)
|
||||
try:
|
||||
_wait_http(f"{APP_URL}/api/health")
|
||||
yield APP_URL
|
||||
finally:
|
||||
_stop(proc)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app_url(app_server: str) -> str:
|
||||
return app_server
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def unconfigured_app(mock_llm: int) -> Iterator[str]:
|
||||
"""The SECOND app boot (task 07): NO ``BOR_DOCS_REPO`` — the inert
|
||||
default the suite must see as absent-for-everyone + 409 push."""
|
||||
proc = _spawn_app(APP_PORT + 1, mock_llm, None)
|
||||
try:
|
||||
_wait_http(f"{UNCONF_URL}/api/health")
|
||||
yield UNCONF_URL
|
||||
finally:
|
||||
_stop(proc)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# KB + table hygiene (the E2E isolation pattern — this suite owns the
|
||||
# KB tables and doc_drafts; both are reset around every test)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _import_fixtures(mock_port: int) -> ImportSummary:
|
||||
kwargs: dict[str, Any] = {
|
||||
"_env_file": None,
|
||||
"llm_base_url": f"http://127.0.0.1:{mock_port}/v1",
|
||||
}
|
||||
settings = Settings(**kwargs) # pyright: ignore[reportCallIssue]
|
||||
return await import_sources([FIXTURES], LLMClient(settings))
|
||||
|
||||
|
||||
def _run_in_thread(coro: Any) -> Any:
|
||||
"""Run a coroutine on a worker thread (the Playwright sync API keeps
|
||||
an asyncio loop on the test thread — the test_chat_rag.py helper)."""
|
||||
import threading
|
||||
|
||||
box: dict[str, Any] = {}
|
||||
|
||||
def runner() -> None:
|
||||
try:
|
||||
box["value"] = asyncio.run(coro)
|
||||
except BaseException as e: # noqa: BLE001 — re-raised on the test thread
|
||||
box["error"] = e
|
||||
|
||||
t = threading.Thread(target=runner)
|
||||
t.start()
|
||||
t.join()
|
||||
if "error" in box:
|
||||
raise box["error"]
|
||||
return box["value"]
|
||||
|
||||
|
||||
def _reset_db(mock_port: int, seed: bool) -> None:
|
||||
with SessionLocal() as db:
|
||||
db.execute(text("TRUNCATE chunks, documents, query_log, doc_drafts"))
|
||||
db.commit()
|
||||
if seed:
|
||||
summary = _run_in_thread(_import_fixtures(mock_port))
|
||||
assert summary.added == 13 # the A9 fixture set (test_chat_rag.py)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _kb_and_clean_drafts(mock_llm: int, db_ready: None) -> Iterator[None]:
|
||||
"""Fresh KB (the deterministic mock embeddings — the grounded
|
||||
questions gate HIGH) + an empty ``doc_drafts`` table per test."""
|
||||
_reset_db(mock_llm, seed=True)
|
||||
yield
|
||||
_reset_db(mock_llm, seed=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Story helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _stream_chat_answer(app_url: str, message: str) -> str:
|
||||
"""Replay one turn through the raw SSE endpoint (the
|
||||
``test_chat_rag.py`` transport pattern) and return the EXACT answer
|
||||
text — the markdown source the UI accumulates into ``m.text``,
|
||||
byte-identical for the deterministic mock (same KB, same question)."""
|
||||
frames: list[dict[str, Any]] = []
|
||||
with httpx.stream(
|
||||
"POST", f"{app_url}/api/chat", json={"message": message}, timeout=120.0
|
||||
) as r:
|
||||
assert r.status_code == 200
|
||||
buf = ""
|
||||
for part in r.iter_text():
|
||||
buf += part
|
||||
while "\n\n" in buf:
|
||||
frame, buf = buf.split("\n\n", 1)
|
||||
if frame.strip().startswith("data:"):
|
||||
frames.append(
|
||||
json.loads(frame.strip().removeprefix("data:").strip())
|
||||
)
|
||||
deltas = [f for f in frames if f.get("type") == "delta"]
|
||||
assert deltas, "the SSE stream must deliver deltas"
|
||||
return "".join(d["text"] for d in deltas)
|
||||
|
||||
|
||||
def _ask(page: Page, app_url: str, question: str) -> None:
|
||||
"""One grounded turn to its DONE state (marker in the bubble + the
|
||||
send button re-enabled — the meta-row buttons land on done)."""
|
||||
page.fill("#message-input", question)
|
||||
page.click("#send-btn")
|
||||
bubble = page.locator(".msg.brain .bubble:not(.typing)").first
|
||||
expect(bubble).to_contain_text(question, timeout=30_000)
|
||||
expect(bubble).to_contain_text(MOCK_ANSWER_MARKER, timeout=30_000)
|
||||
expect(page.locator("#send-btn")).to_be_enabled(timeout=30_000)
|
||||
expect(page.locator("#send-label")).to_have_text("Send")
|
||||
|
||||
|
||||
def _login_admin(page: Page, app_url: str) -> None:
|
||||
"""Real form login landing on the chat (admin settled)."""
|
||||
login(page, app_url, next="/")
|
||||
expect(page).to_have_url(app_url + "/", timeout=30_000)
|
||||
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=30_000)
|
||||
|
||||
|
||||
def _open_edit_screen(page: Page) -> str:
|
||||
"""Click the save action, wait for the navigation, return the draft
|
||||
token from the URL (the uuid4 credential)."""
|
||||
page.click(".save-as-doc-btn")
|
||||
page.wait_for_url(DRAFT_URL_RE, timeout=30_000)
|
||||
token = parse_qs(urlsplit(page.url).query).get("draft", [""])[0]
|
||||
assert re.fullmatch(r"[0-9a-f-]{36}", token), f"no draft token in {page.url}"
|
||||
expect(page.locator("#doc-edit-gate")).to_be_hidden(timeout=30_000)
|
||||
expect(page.locator("#doc-edit-content")).to_be_visible(timeout=30_000)
|
||||
return token
|
||||
|
||||
|
||||
def _push_and_read_sha(page: Page) -> tuple[str, str]:
|
||||
"""Submit the edit screen's push; wait for the success line and
|
||||
return (branch, sha7) exactly as the live region reported them."""
|
||||
page.click("#push-doc-btn")
|
||||
status = page.locator("#push-status")
|
||||
expect(status).to_contain_text(f"Pushed to {BRANCH}", timeout=60_000)
|
||||
line = status.inner_text().strip()
|
||||
m = SUCCESS_SHA_RE.search(line)
|
||||
assert m, f"the success line carries no commit sha: {line!r}"
|
||||
return BRANCH, m.group(1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. The whole loop: save → edit → push → the bare repo agrees
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_save_edit_push(
|
||||
page: Page,
|
||||
app_url: str,
|
||||
mock_llm: int,
|
||||
db_ready: None,
|
||||
docs_repo: SimpleNamespace,
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
_login_admin(page, app_url)
|
||||
_ask(page, app_url, QUESTION_1)
|
||||
|
||||
# The "Save as doc" action is on the completed brain bubble…
|
||||
btn = page.locator(".msg.brain .save-as-doc-btn")
|
||||
expect(btn).to_have_count(1)
|
||||
expect(btn).to_contain_text("Save as doc")
|
||||
# …bottom-right: its left edge sits past the bubble's midline
|
||||
# (margin-inline-start: auto in the meta row).
|
||||
msg_box = page.locator(".msg.brain").bounding_box()
|
||||
btn_box = btn.bounding_box()
|
||||
assert msg_box is not None and btn_box is not None
|
||||
midline = msg_box["x"] + msg_box["width"] / 2
|
||||
assert btn_box["x"] > midline, (
|
||||
f"save button x={btn_box['x']:.0f} is not past the bubble midline "
|
||||
f"{midline:.0f} — it must sit bottom-right"
|
||||
)
|
||||
|
||||
# Click → /doc-edit.html?draft=<uuid4>, prefilled.
|
||||
_open_edit_screen(page)
|
||||
expect(page.locator("#draft-title")).to_have_value(QUESTION_1) # auto-title
|
||||
expect(page.locator("#draft-path")).to_have_value(
|
||||
f"docs/{doc_slug(QUESTION_1)}.md"
|
||||
)
|
||||
# Body == the rendered answer's MARKDOWN SOURCE: the SSE replay
|
||||
# recovers the exact bytes the UI accumulated (the mock is
|
||||
# byte-deterministic on the same KB + question) — and they are
|
||||
# plain markdown, not rendered HTML.
|
||||
raw = _stream_chat_answer(app_url, QUESTION_1)
|
||||
assert MOCK_ANSWER_MARKER in raw and QUESTION_1 in raw
|
||||
assert "<" not in raw and ">" not in raw, "the draft body must be markdown, not HTML"
|
||||
expect(page.locator("#draft-body")).to_have_value(raw)
|
||||
|
||||
# Modify the doc (the story's "modify before [pushing]"): a
|
||||
# distinctive marker line the bare repo must show after the push.
|
||||
edited = f"{raw}\n\n{E2E_MARKER}"
|
||||
page.fill("#draft-body", edited)
|
||||
|
||||
# Push → the live region reports the branch + a 7-char commit sha…
|
||||
branch, sha7 = _push_and_read_sha(page)
|
||||
assert branch == BRANCH
|
||||
|
||||
# …and the BARE REPO agrees (the source of truth — not the UI):
|
||||
# the file on the branch is exactly the edited body…
|
||||
path = f"docs/{doc_slug(QUESTION_1)}.md"
|
||||
shown = _git(["-C", str(docs_repo.bare), "show", f"{BRANCH}:{path}"])
|
||||
assert shown == edited
|
||||
# …and the branch tip's first 7 chars are the sha the UI reported.
|
||||
tip = _git(["-C", str(docs_repo.bare), "rev-parse", BRANCH]).strip()
|
||||
assert tip.startswith(sha7), f"UI sha {sha7} != bare repo tip {tip}"
|
||||
# First push: the branch exists and is exactly one commit beyond
|
||||
# main (created by the push — the remote had no bor-docs before).
|
||||
assert (
|
||||
_git(["-C", str(docs_repo.bare), "rev-list", "--count", f"{BASE_BRANCH}..{BRANCH}"])
|
||||
.strip()
|
||||
== "1"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. A second save fast-forwards: two commits, file 1 untouched
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_second_push_fast_forwards(
|
||||
page: Page,
|
||||
app_url: str,
|
||||
mock_llm: int,
|
||||
db_ready: None,
|
||||
docs_repo: SimpleNamespace,
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
_login_admin(page, app_url)
|
||||
_ask(page, app_url, QUESTION_2)
|
||||
|
||||
# Save the second answer (different question → different slug)…
|
||||
expect(page.locator(".msg.brain .save-as-doc-btn")).to_have_count(1)
|
||||
_open_edit_screen(page)
|
||||
expect(page.locator("#draft-title")).to_have_value(QUESTION_2)
|
||||
expect(page.locator("#draft-path")).to_have_value(
|
||||
f"docs/{doc_slug(QUESTION_2)}.md"
|
||||
)
|
||||
raw2 = _stream_chat_answer(app_url, QUESTION_2)
|
||||
expect(page.locator("#draft-body")).to_have_value(raw2)
|
||||
|
||||
# …and push WITHOUT editing — a new commit on the same branch.
|
||||
_push_and_read_sha(page)
|
||||
|
||||
# The bare repo: exactly two commits beyond main (fast-forward,
|
||||
# never a force-push or a reset)…
|
||||
assert (
|
||||
_git(["-C", str(docs_repo.bare), "rev-list", "--count", f"{BASE_BRANCH}..{BRANCH}"])
|
||||
.strip()
|
||||
== "2"
|
||||
)
|
||||
# …file 2 landed with its unedited body…
|
||||
path2 = f"docs/{doc_slug(QUESTION_2)}.md"
|
||||
assert _git(["-C", str(docs_repo.bare), "show", f"{BRANCH}:{path2}"]) == raw2
|
||||
# …and file 1 from test 1 is still at its path, byte-for-byte
|
||||
# (deterministic reconstruction: the mock answer + the marker line).
|
||||
path1 = f"docs/{doc_slug(QUESTION_1)}.md"
|
||||
expected_first = f"{_stream_chat_answer(app_url, QUESTION_1)}\n\n{E2E_MARKER}"
|
||||
assert _git(["-C", str(docs_repo.bare), "show", f"{BRANCH}:{path1}"]) == expected_first
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Guest: no button, 403 on the draft API, the edit screen gates
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_guest_has_no_button(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
|
||||
# No login (the conftest page fixture is a fresh context). Track
|
||||
# every /api/doc-drafts request the PAGES make — the guest flow
|
||||
# must never reach the admin API.
|
||||
drafts_calls: list[str] = []
|
||||
page.on(
|
||||
"request",
|
||||
lambda r: drafts_calls.append(r.url) if "/api/doc-drafts" in r.url else None,
|
||||
)
|
||||
|
||||
page.goto(app_url)
|
||||
expect(page.locator("#sign-in-link")).to_be_visible(timeout=30_000)
|
||||
_ask(page, app_url, QUESTION_1) # the grounded answer streams for guests too
|
||||
|
||||
# The "Save as doc" action is admin-only: ABSENT (not hidden) on
|
||||
# the completed bubble, whatever the docs config says.
|
||||
expect(page.locator(".save-as-doc-btn")).to_have_count(0)
|
||||
|
||||
# The draft API 403s anonymous callers (httpx, no cookie at all).
|
||||
r = httpx.post(
|
||||
f"{app_url}/api/doc-drafts",
|
||||
json={"title": "guest", "path": "docs/guest.md", "body": "nope"},
|
||||
timeout=10,
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
# The edit screen renders the admin gate with NO draft data in the
|
||||
# DOM, and the page itself made zero draft API calls.
|
||||
page.goto(app_url + "/doc-edit.html")
|
||||
expect(page.locator("#doc-edit-gate")).to_be_visible(timeout=30_000)
|
||||
expect(page.locator("#doc-edit-content")).to_be_hidden()
|
||||
assert page.input_value("#draft-title") == ""
|
||||
assert page.input_value("#draft-path") == ""
|
||||
assert page.input_value("#draft-body") == ""
|
||||
assert drafts_calls == [], f"guest pages called the draft API: {drafts_calls}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Unconfigured (BOR_DOCS_REPO empty): inert for everyone, 409 push
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_unconfigured_hides_button(
|
||||
page: Page, unconfigured_app: str, mock_llm: int, db_ready: None
|
||||
) -> None:
|
||||
page.set_default_timeout(30_000)
|
||||
_login_admin(page, unconfigured_app)
|
||||
_ask(page, unconfigured_app, QUESTION_1)
|
||||
|
||||
# docs_repo_configured false → the button is hidden for EVERYONE,
|
||||
# admin included (the optional-feature pattern — inert by default).
|
||||
expect(page.locator(".save-as-doc-btn")).to_have_count(0)
|
||||
|
||||
# Drafts are repo-independent: an admin can still create one…
|
||||
cookies = _admin_cookies(page)
|
||||
r = httpx.post(
|
||||
f"{unconfigured_app}/api/doc-drafts",
|
||||
json={
|
||||
"title": "Unconfigured draft",
|
||||
"path": "docs/unconfigured.md",
|
||||
"body": "A draft while no docs repo is configured.",
|
||||
},
|
||||
timeout=10,
|
||||
cookies=cookies,
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
token = r.json()["token"]
|
||||
|
||||
# …but pushing 409s, naming the missing variable (D3: fail loud,
|
||||
# inert by default).
|
||||
r = httpx.post(
|
||||
f"{unconfigured_app}/api/doc-drafts/{token}/push", timeout=10, cookies=cookies
|
||||
)
|
||||
assert r.status_code == 409
|
||||
assert "BOR_DOCS_REPO" in r.json()["detail"]
|
||||
@@ -18,13 +18,16 @@ def test_health_reports_ok(client) -> None:
|
||||
|
||||
|
||||
def test_config_returns_default_app_metadata(client) -> None:
|
||||
"""GET /api/config is public (anonymous) and returns exactly two keys."""
|
||||
"""GET /api/config is public (anonymous) and returns exactly three
|
||||
keys — the phase-39 app metadata + the phase-59 docs flag (inert
|
||||
false while BOR_DOCS_REPO is empty — the "Save as doc" gating)."""
|
||||
r = client.get("/api/config")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert set(body) == {"app_name", "version"}
|
||||
assert set(body) == {"app_name", "version", "docs_repo_configured"}
|
||||
assert body["app_name"] == "Brain of Reese"
|
||||
assert body["version"] == get_settings().app_version
|
||||
assert body["docs_repo_configured"] is False
|
||||
|
||||
|
||||
def test_config_follows_overridden_app_name(client) -> None:
|
||||
@@ -39,9 +42,32 @@ def test_config_follows_overridden_app_name(client) -> None:
|
||||
r = client.get("/api/config")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert set(body) == {"app_name", "version"}
|
||||
assert set(body) == {"app_name", "version", "docs_repo_configured"}
|
||||
assert body["app_name"] == "Brain of Testy"
|
||||
assert body["version"] == "0.1.0"
|
||||
assert body["docs_repo_configured"] is False
|
||||
finally:
|
||||
fastapi_app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_config_docs_flag_tracks_settings(client) -> None:
|
||||
"""Phase 59 (task 05): ``docs_repo_configured`` mirrors
|
||||
``settings.docs_configured`` — a real bool (never a truthy string)
|
||||
that flips true the moment BOR_DOCS_REPO is non-empty: that flag is
|
||||
the entire frontend gating of the "Save as doc" button."""
|
||||
from app.config import Settings
|
||||
from app.main import app as fastapi_app
|
||||
|
||||
fastapi_app.dependency_overrides[get_settings] = lambda: Settings(
|
||||
app_name="Brain of Testy",
|
||||
docs_repo="/srv/docs-repo",
|
||||
)
|
||||
try:
|
||||
r = client.get("/api/config")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert isinstance(body["docs_repo_configured"], bool)
|
||||
assert body["docs_repo_configured"] is True
|
||||
finally:
|
||||
fastapi_app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@@ -0,0 +1,536 @@
|
||||
"""Integration: doc-drafts API (phase 59, task 02) — create / get / update.
|
||||
|
||||
The draft lifecycle the edit screen runs on: create (from a response),
|
||||
fetch by token, update (modify before push) — all admin-only
|
||||
(router-wide ``require_admin``), all path-guard-railed (no path that can
|
||||
escape the repo root).
|
||||
|
||||
Real Postgres (``podman compose up -d db``); no LLM involved — drafts
|
||||
are plain rows, so the suite is deterministic without a fake.
|
||||
|
||||
Requires: podman compose up -d db
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select, text
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.main import app as fastapi_app
|
||||
from app.models import DocDraft
|
||||
|
||||
TITLE = "How do I deploy a new service?"
|
||||
PATH = "docs/note.md"
|
||||
BODY = "# Answer\n\nSome **markdown** body."
|
||||
BASE_BRANCH = "main"
|
||||
DOCS_BRANCH = "bor-docs"
|
||||
|
||||
|
||||
def _settings(**kwargs: Any) -> Settings:
|
||||
"""Build Settings without reading a .env file (deterministic tests)."""
|
||||
kwargs.setdefault("_env_file", None)
|
||||
return Settings(**kwargs) # pyright: ignore[reportCallIssue] (kwarg exists at runtime)
|
||||
|
||||
|
||||
def _git_available() -> bool:
|
||||
try:
|
||||
proc = subprocess.run(["git", "--version"], capture_output=True, check=False)
|
||||
return proc.returncode == 0
|
||||
except (FileNotFoundError, OSError):
|
||||
return False
|
||||
|
||||
|
||||
#: The push tests drive a real local git repo — skipped (not failed) on a
|
||||
#: machine without the git CLI (the task-03 unit-suite guard).
|
||||
GIT = _git_available()
|
||||
|
||||
|
||||
def _git(cwd: Path, *argv: str) -> str:
|
||||
"""Run git for the tests themselves (fixture setup + assertions)."""
|
||||
proc = subprocess.run(["git", *argv], cwd=cwd, capture_output=True, text=True, check=False)
|
||||
assert proc.returncode == 0, f"git {' '.join(argv)} failed: {proc.stderr}"
|
||||
return proc.stdout
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clean_drafts(db) -> Iterator[None]:
|
||||
"""doc_drafts is global state: reset around every test."""
|
||||
db.execute(text("TRUNCATE doc_drafts"))
|
||||
db.commit()
|
||||
yield
|
||||
db.execute(text("TRUNCATE doc_drafts"))
|
||||
db.commit()
|
||||
|
||||
|
||||
def _create(admin_client: TestClient, **overrides) -> dict:
|
||||
"""POST a well-formed draft (201) and return the response body."""
|
||||
payload = {"title": TITLE, "path": PATH, "body": BODY, **overrides}
|
||||
r = admin_client.post("/api/doc-drafts", json=payload)
|
||||
assert r.status_code == 201, r.text
|
||||
return r.json()
|
||||
|
||||
|
||||
def _backdate_updated_at(db, token: uuid.UUID) -> None:
|
||||
"""Push the row's ``updated_at`` one hour back (raw SQL — a hand-
|
||||
written UPDATE does not trigger the column's onupdate default), so
|
||||
a subsequent API write's bump is observable deterministically."""
|
||||
db.execute(
|
||||
text("UPDATE doc_drafts SET updated_at = now() - interval '1 hour' WHERE token = :t"),
|
||||
{"t": token},
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
# ---------- create (POST) ----------
|
||||
|
||||
|
||||
def test_create_returns_201_with_all_fields_and_draft_status(
|
||||
admin_client: TestClient, db
|
||||
) -> None:
|
||||
r = admin_client.post("/api/doc-drafts", json={"title": TITLE, "path": PATH, "body": BODY})
|
||||
|
||||
assert r.status_code == 201
|
||||
body = r.json()
|
||||
assert body["title"] == TITLE
|
||||
assert body["path"] == PATH
|
||||
assert body["body"] == BODY
|
||||
assert body["status"] == "draft"
|
||||
# The push feedback columns are NULL while still a draft.
|
||||
assert body["branch"] is None
|
||||
assert body["commit_sha"] is None
|
||||
# The token: present, non-NULL, a valid (unguessable) UUID.
|
||||
assert body["token"]
|
||||
tok = uuid.UUID(body["token"])
|
||||
assert body["created_at"]
|
||||
assert body["updated_at"]
|
||||
|
||||
# The row is in Postgres under the same token (the URL credential).
|
||||
row = db.execute(select(DocDraft).where(DocDraft.token == tok)).scalars().one()
|
||||
assert row.title == TITLE
|
||||
assert row.path == PATH
|
||||
assert row.body == BODY
|
||||
assert row.status == "draft"
|
||||
|
||||
|
||||
def test_create_strips_title_body_and_path(admin_client: TestClient) -> None:
|
||||
body = _create(
|
||||
admin_client, title=f" {TITLE} ", path=f" {PATH} ", body=f"\n{BODY}\n"
|
||||
)
|
||||
assert body["title"] == TITLE
|
||||
assert body["path"] == PATH
|
||||
assert body["body"] == BODY
|
||||
|
||||
|
||||
def test_create_rejects_blank_title_body_path(admin_client: TestClient) -> None:
|
||||
# Whitespace-only values: past pydantic's min_length=1, caught by the
|
||||
# API's non-empty-after-strip rule (422), nothing stored.
|
||||
for overrides in ({"title": " "}, {"body": " \t\n "}, {"path": " "}):
|
||||
payload = {"title": TITLE, "path": PATH, "body": BODY, **overrides}
|
||||
assert admin_client.post("/api/doc-drafts", json=payload).status_code == 422
|
||||
# Truly empty title/body: pydantic 422 (min_length=1).
|
||||
empty_title = {"title": "", "path": PATH, "body": BODY}
|
||||
assert admin_client.post("/api/doc-drafts", json=empty_title).status_code == 422
|
||||
empty_body = {"title": TITLE, "path": PATH, "body": ""}
|
||||
assert admin_client.post("/api/doc-drafts", json=empty_body).status_code == 422
|
||||
|
||||
|
||||
# ---------- path guard-rails (shared with the push endpoint) ----------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("bad_path", "rule_in_detail"),
|
||||
[
|
||||
("/etc/passwd", "absolute"),
|
||||
("../x.md", "'..'"),
|
||||
("a/b/../c.md", "'..'"),
|
||||
("no-suffix", "suffix"),
|
||||
(" ", "empty"),
|
||||
],
|
||||
)
|
||||
def test_create_path_guard_rejects_each_rule_with_422(
|
||||
admin_client: TestClient, bad_path: str, rule_in_detail: str
|
||||
) -> None:
|
||||
r = admin_client.post(
|
||||
"/api/doc-drafts", json={"title": TITLE, "path": bad_path, "body": BODY}
|
||||
)
|
||||
assert r.status_code == 422
|
||||
assert rule_in_detail in r.json()["detail"]
|
||||
|
||||
|
||||
def test_create_accepts_repo_relative_path_with_suffix(admin_client: TestClient) -> None:
|
||||
body = _create(admin_client, path="docs/note.md")
|
||||
assert body["path"] == "docs/note.md"
|
||||
|
||||
|
||||
def test_put_path_guard_rejects_traversal(admin_client: TestClient) -> None:
|
||||
created = _create(admin_client)
|
||||
for bad in ("/etc/passwd", "../x.md", "a/b/../c.md", "no-suffix"):
|
||||
r = admin_client.put(
|
||||
f"/api/doc-drafts/{created['token']}", json={"path": bad}
|
||||
)
|
||||
assert r.status_code == 422, bad
|
||||
# The row is untouched by the rejected updates.
|
||||
body = admin_client.get(f"/api/doc-drafts/{created['token']}").json()
|
||||
assert body["path"] == PATH
|
||||
|
||||
|
||||
# ---------- get (by token) ----------
|
||||
|
||||
|
||||
def test_get_round_trips_created_draft(admin_client: TestClient) -> None:
|
||||
created = _create(admin_client)
|
||||
|
||||
r = admin_client.get(f"/api/doc-drafts/{created['token']}")
|
||||
|
||||
assert r.status_code == 200
|
||||
assert r.json() == created
|
||||
|
||||
|
||||
def test_get_unknown_token_returns_404(admin_client: TestClient) -> None:
|
||||
r = admin_client.get(f"/api/doc-drafts/{uuid.uuid4()}")
|
||||
assert r.status_code == 404
|
||||
assert r.json() == {"detail": "draft not found"}
|
||||
|
||||
|
||||
def test_get_malformed_token_returns_422(admin_client: TestClient) -> None:
|
||||
assert admin_client.get("/api/doc-drafts/not-a-uuid").status_code == 422
|
||||
|
||||
|
||||
# ---------- update (PUT) ----------
|
||||
|
||||
|
||||
def test_put_partial_body_only_keeps_title_and_path(admin_client: TestClient, db) -> None:
|
||||
created = _create(admin_client)
|
||||
token = uuid.UUID(created["token"])
|
||||
_backdate_updated_at(db, token)
|
||||
|
||||
r = admin_client.put(f"/api/doc-drafts/{token}", json={"body": "# v2\n\nEdited."})
|
||||
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["title"] == TITLE # absent → unchanged
|
||||
assert body["path"] == PATH # absent → unchanged
|
||||
assert body["body"] == "# v2\n\nEdited."
|
||||
assert body["status"] == "draft"
|
||||
assert body["created_at"] == created["created_at"] # editing does not redate creation
|
||||
# updated_at was bumped past the backdated value.
|
||||
assert datetime.fromisoformat(body["updated_at"]) > datetime.fromisoformat(
|
||||
created["updated_at"]
|
||||
)
|
||||
|
||||
|
||||
def test_put_replaces_all_fields_when_supplied(admin_client: TestClient) -> None:
|
||||
created = _create(admin_client)
|
||||
|
||||
r = admin_client.put(
|
||||
f"/api/doc-drafts/{created['token']}",
|
||||
json={"title": "New title", "path": "docs/other.md", "body": "New body."},
|
||||
)
|
||||
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["title"] == "New title"
|
||||
assert body["path"] == "docs/other.md"
|
||||
assert body["body"] == "New body."
|
||||
assert body["status"] == "draft"
|
||||
|
||||
|
||||
def test_put_noop_still_bumps_updated_at(admin_client: TestClient, db) -> None:
|
||||
"""A PUT whose supplied values are all identical (or empty body)
|
||||
changes no stored value — the ORM flushes nothing — yet the contract
|
||||
is that a PUT bumps ``updated_at`` (the raw-UPDATE fallback)."""
|
||||
created = _create(admin_client)
|
||||
token = uuid.UUID(created["token"])
|
||||
_backdate_updated_at(db, token)
|
||||
|
||||
r = admin_client.put(f"/api/doc-drafts/{token}", json={"body": BODY}) # identical
|
||||
|
||||
assert r.status_code == 200
|
||||
assert r.json()["body"] == BODY
|
||||
assert datetime.fromisoformat(r.json()["updated_at"]) > datetime.fromisoformat(
|
||||
created["updated_at"]
|
||||
)
|
||||
|
||||
# And an empty partial body (no fields at all) does the same.
|
||||
_backdate_updated_at(db, token)
|
||||
r2 = admin_client.put(f"/api/doc-drafts/{token}", json={})
|
||||
assert r2.status_code == 200
|
||||
assert datetime.fromisoformat(r2.json()["updated_at"]) > datetime.fromisoformat(
|
||||
created["updated_at"]
|
||||
)
|
||||
|
||||
|
||||
def test_put_resets_pushed_draft_to_draft(admin_client: TestClient, db) -> None:
|
||||
created = _create(admin_client)
|
||||
token = uuid.UUID(created["token"])
|
||||
|
||||
# Mark the draft pushed directly in the DB (the push endpoint's job
|
||||
# lands in task 04 — here we pin the edit-side consequence).
|
||||
row = db.execute(select(DocDraft).where(DocDraft.token == token)).scalars().one()
|
||||
row.status = "pushed"
|
||||
row.branch = "bor-docs"
|
||||
row.commit_sha = "a" * 40
|
||||
db.commit()
|
||||
|
||||
r = admin_client.put(f"/api/doc-drafts/{token}", json={"body": "Edited after push."})
|
||||
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["status"] == "draft" # the stored sha no longer describes the body
|
||||
assert body["body"] == "Edited after push."
|
||||
assert body["title"] == TITLE # absent → unchanged
|
||||
assert body["path"] == PATH # absent → unchanged
|
||||
# The last push stays visible until the next push overwrites it.
|
||||
assert body["branch"] == "bor-docs"
|
||||
assert body["commit_sha"] == "a" * 40
|
||||
|
||||
|
||||
def test_put_unknown_token_returns_404(admin_client: TestClient) -> None:
|
||||
r = admin_client.put(f"/api/doc-drafts/{uuid.uuid4()}", json={"body": "x"})
|
||||
assert r.status_code == 404
|
||||
assert r.json() == {"detail": "draft not found"}
|
||||
|
||||
|
||||
def test_put_malformed_token_returns_422(admin_client: TestClient) -> None:
|
||||
assert admin_client.put("/api/doc-drafts/not-a-uuid", json={"body": "x"}).status_code == 422
|
||||
|
||||
|
||||
def test_put_rejects_blank_fields_and_leaves_row_unchanged(admin_client: TestClient) -> None:
|
||||
created = _create(admin_client)
|
||||
for bad in ({"title": " "}, {"body": " \t "}, {"path": " "}):
|
||||
assert admin_client.put(f"/api/doc-drafts/{created['token']}", json=bad).status_code == 422
|
||||
assert admin_client.get(f"/api/doc-drafts/{created['token']}").json() == created
|
||||
|
||||
|
||||
# ---------- auth: anonymous gets 403 on every route ----------
|
||||
|
||||
|
||||
def test_anonymous_gets_403_on_all_routes(admin_client: TestClient, db) -> None:
|
||||
created = _create(admin_client, body="admin-created")
|
||||
anon = TestClient(fastapi_app) # fresh jar: truly anonymous (no cookie)
|
||||
|
||||
r = anon.post("/api/doc-drafts", json={"title": "x", "path": "docs/x.md", "body": "y"})
|
||||
assert r.status_code == 403
|
||||
assert r.json() == {"detail": "admin only"}
|
||||
assert anon.get(f"/api/doc-drafts/{created['token']}").status_code == 403
|
||||
assert anon.put(f"/api/doc-drafts/{created['token']}", json={"body": "nope"}).status_code == 403
|
||||
|
||||
# The anonymous attempts changed nothing: exactly the admin's draft
|
||||
# exists, untouched.
|
||||
rows = db.execute(select(DocDraft)).scalars().all()
|
||||
assert len(rows) == 1
|
||||
assert rows[0].body == "admin-created"
|
||||
|
||||
|
||||
# ---------- push (POST /{token}/push — task 04) ----------
|
||||
#
|
||||
# The push tests run against a **real local bare repo** (the task-03
|
||||
# unit pattern) and inject the endpoint's settings via the app's
|
||||
# ``Depends(get_settings)`` override (the house pattern —
|
||||
# ``app/api/config.py`` takes ``settings: Settings = Depends(
|
||||
# get_settings)``). Result assertions read the bare repo's state
|
||||
# (``git show <branch>:<path>``, ``git rev-parse``), not the response
|
||||
# alone.
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def bare_docs_repo(tmp_path: Path) -> Path:
|
||||
"""A bare origin seeded with one commit on ``main`` (``README.md``)."""
|
||||
if not GIT:
|
||||
pytest.skip("git is not available on this machine")
|
||||
bare = tmp_path / "bare.git"
|
||||
_git(tmp_path, "init", "--bare", str(bare))
|
||||
seed = tmp_path / "seed"
|
||||
_git(tmp_path, "clone", str(bare), str(seed))
|
||||
(seed / "README.md").write_text("# docs\n", encoding="utf-8")
|
||||
_git(seed, "checkout", "-B", BASE_BRANCH)
|
||||
_git(
|
||||
seed,
|
||||
"-c", "commit.gpgsign=false",
|
||||
"-c", "user.name=Test",
|
||||
"-c", "user.email=t@example.com",
|
||||
"add", "README.md",
|
||||
)
|
||||
_git(
|
||||
seed,
|
||||
"-c", "commit.gpgsign=false",
|
||||
"-c", "user.name=Test",
|
||||
"-c", "user.email=t@example.com",
|
||||
"commit", "-m", "seed README",
|
||||
)
|
||||
_git(seed, "push", "origin", BASE_BRANCH)
|
||||
return bare
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def docs_push_settings(bare_docs_repo: Path, tmp_path: Path) -> Iterator[Settings]:
|
||||
"""Settings pointing at the fixture bare repo, injected into the
|
||||
endpoint's settings dependency; the override is removed after the
|
||||
test (no leak into other tests' settings)."""
|
||||
settings = _settings(
|
||||
docs_repo=str(bare_docs_repo),
|
||||
docs_branch=DOCS_BRANCH,
|
||||
docs_base_branch=BASE_BRANCH,
|
||||
docs_work_dir=str(tmp_path / "docs-workdir"),
|
||||
)
|
||||
fastapi_app.dependency_overrides[get_settings] = lambda: settings
|
||||
try:
|
||||
yield settings
|
||||
finally:
|
||||
fastapi_app.dependency_overrides.pop(get_settings, None)
|
||||
|
||||
|
||||
def test_push_success_commits_and_records_branch_and_sha(
|
||||
admin_client: TestClient, db, docs_push_settings: Settings, bare_docs_repo: Path
|
||||
) -> None:
|
||||
created = _create(admin_client)
|
||||
_backdate_updated_at(db, uuid.UUID(created["token"]))
|
||||
|
||||
r = admin_client.post(f"/api/doc-drafts/{created['token']}/push")
|
||||
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["status"] == "pushed"
|
||||
assert body["branch"] == DOCS_BRANCH
|
||||
sha = body["commit_sha"]
|
||||
assert len(sha) == 40
|
||||
# The source of truth is the bare repo's state — not the response:
|
||||
# the file landed on the branch at exactly the returned sha, with
|
||||
# the draft's body, under the fixed per-invocation identity.
|
||||
assert _git(bare_docs_repo, "rev-parse", DOCS_BRANCH).strip() == sha
|
||||
assert _git(bare_docs_repo, "show", f"{DOCS_BRANCH}:{PATH}") == BODY
|
||||
ident = _git(bare_docs_repo, "log", "-1", DOCS_BRANCH, "--format=%an <%ae>").strip()
|
||||
assert ident == "Brain of Reese <bor@local>"
|
||||
assert _git(bare_docs_repo, "log", "-1", DOCS_BRANCH, "--format=%s").strip() == (
|
||||
f"docs: {TITLE}"
|
||||
)
|
||||
|
||||
# The DB row records the outcome (status + branch + sha), and the
|
||||
# GET endpoint reports it.
|
||||
row = db.execute(
|
||||
select(DocDraft).where(DocDraft.token == uuid.UUID(created["token"]))
|
||||
).scalars().one()
|
||||
assert row.status == "pushed"
|
||||
assert row.branch == DOCS_BRANCH
|
||||
assert row.commit_sha == sha
|
||||
got = admin_client.get(f"/api/doc-drafts/{created['token']}").json()
|
||||
assert got["status"] == "pushed"
|
||||
assert got["branch"] == DOCS_BRANCH
|
||||
assert got["commit_sha"] == sha
|
||||
# The success bumped updated_at (past the backdated value).
|
||||
assert datetime.fromisoformat(got["updated_at"]) > datetime.fromisoformat(
|
||||
created["updated_at"]
|
||||
)
|
||||
|
||||
|
||||
def test_push_unconfigured_returns_409_naming_variable(admin_client: TestClient, db) -> None:
|
||||
"""Default settings (``docs_repo=""``) → 409 naming the variable;
|
||||
the row stays a draft (D3: inert by default)."""
|
||||
created = _create(admin_client)
|
||||
fastapi_app.dependency_overrides[get_settings] = lambda: _settings() # docs_repo=""
|
||||
try:
|
||||
r = admin_client.post(f"/api/doc-drafts/{created['token']}/push")
|
||||
finally:
|
||||
fastapi_app.dependency_overrides.pop(get_settings, None)
|
||||
|
||||
assert r.status_code == 409
|
||||
assert r.json() == {"detail": "docs repo not configured (BOR_DOCS_REPO)"}
|
||||
body = admin_client.get(f"/api/doc-drafts/{created['token']}").json()
|
||||
assert body["status"] == "draft"
|
||||
assert body["branch"] is None
|
||||
assert body["commit_sha"] is None
|
||||
|
||||
|
||||
def test_push_non_repo_dir_returns_502_with_git_stderr(
|
||||
admin_client: TestClient, db, tmp_path: Path
|
||||
) -> None:
|
||||
"""A configured repo that is not a git repo → 502 with git's stderr
|
||||
in the detail (the ``GitSyncError`` → ``detail`` mapping);
|
||||
the row stays a draft (only a success mutates)."""
|
||||
plain = tmp_path / "not-a-repo"
|
||||
plain.mkdir()
|
||||
(plain / "file.txt").write_text("not a repo\n", encoding="utf-8")
|
||||
fastapi_app.dependency_overrides[
|
||||
get_settings
|
||||
] = lambda: _settings(
|
||||
docs_repo=str(plain),
|
||||
docs_branch=DOCS_BRANCH,
|
||||
docs_base_branch=BASE_BRANCH,
|
||||
docs_work_dir=str(tmp_path / "docs-workdir"),
|
||||
)
|
||||
created = _create(admin_client)
|
||||
try:
|
||||
r = admin_client.post(f"/api/doc-drafts/{created['token']}/push")
|
||||
finally:
|
||||
fastapi_app.dependency_overrides.pop(get_settings, None)
|
||||
|
||||
assert r.status_code == 502
|
||||
detail = r.json()["detail"]
|
||||
# git's stderr is surfaced (the clone refusal of a non-repo dir).
|
||||
assert "failed" in detail
|
||||
assert "fatal: repository" in detail
|
||||
body = admin_client.get(f"/api/doc-drafts/{created['token']}").json()
|
||||
assert body["status"] == "draft"
|
||||
assert body["branch"] is None
|
||||
assert body["commit_sha"] is None
|
||||
|
||||
|
||||
def test_push_unknown_token_returns_404(
|
||||
admin_client: TestClient, docs_push_settings: Settings
|
||||
) -> None:
|
||||
r = admin_client.post(f"/api/doc-drafts/{uuid.uuid4()}/push")
|
||||
assert r.status_code == 404
|
||||
assert r.json() == {"detail": "draft not found"}
|
||||
|
||||
|
||||
def test_push_rejects_bad_stored_path_with_422(
|
||||
admin_client: TestClient, db, docs_push_settings: Settings
|
||||
) -> None:
|
||||
"""A row whose stored path no longer passes the guard-rails must not
|
||||
be pushable (422 naming the rule — re-validated on push, task 02
|
||||
helper); the row is untouched."""
|
||||
bad = DocDraft(token=uuid.uuid4(), title=TITLE, path="../evil.md", body=BODY)
|
||||
db.add(bad)
|
||||
db.commit()
|
||||
db.refresh(bad)
|
||||
|
||||
r = admin_client.post(f"/api/doc-drafts/{bad.token}/push")
|
||||
|
||||
assert r.status_code == 422
|
||||
assert "'..'" in r.json()["detail"]
|
||||
row = db.get(DocDraft, bad.id)
|
||||
assert row is not None
|
||||
assert row.status == "draft"
|
||||
assert row.branch is None
|
||||
assert row.commit_sha is None
|
||||
|
||||
|
||||
def test_push_anonymous_returns_403(
|
||||
admin_client: TestClient,
|
||||
db,
|
||||
docs_push_settings: Settings,
|
||||
bare_docs_repo: Path,
|
||||
) -> None:
|
||||
created = _create(admin_client)
|
||||
anon = TestClient(fastapi_app) # fresh jar: truly anonymous (no cookie)
|
||||
|
||||
r = anon.post(f"/api/doc-drafts/{created['token']}/push")
|
||||
|
||||
assert r.status_code == 403
|
||||
assert r.json() == {"detail": "admin only"}
|
||||
# The anonymous push attempt changed nothing: no branch on the bare
|
||||
# repo, the row is still a draft (guest reads 403 too).
|
||||
assert _git(bare_docs_repo, "branch", "--list", DOCS_BRANCH).strip() == ""
|
||||
assert anon.get(f"/api/doc-drafts/{created['token']}").status_code == 403
|
||||
row = db.execute(
|
||||
select(DocDraft).where(DocDraft.token == uuid.UUID(created["token"]))
|
||||
).scalars().one()
|
||||
assert row.status == "draft"
|
||||
@@ -0,0 +1,322 @@
|
||||
"""Integration: migration 0011 (doc_drafts) schema contract.
|
||||
|
||||
Drives the **real Alembic engine** against the live dev database
|
||||
(``podman compose up -d db``), mirroring the house pattern of
|
||||
``test_migration_0010.py`` (information_schema / pg_indexes assertions
|
||||
on the state the migration must leave). The tests target revision
|
||||
``0011`` explicitly so later migrations cannot break them:
|
||||
|
||||
* upgrade 0010 → 0011 → the ``doc_drafts`` table exists with the full
|
||||
column contract (``id`` UUID PK; ``token`` UUID NOT NULL + the UNIQUE
|
||||
index ``ix_doc_drafts_token`` — the URL credential; ``title`` /
|
||||
``path`` / ``body`` TEXT NOT NULL; ``status`` TEXT NOT NULL default
|
||||
'draft'; ``branch`` / ``commit_sha`` TEXT NULL; ``created_at`` /
|
||||
``updated_at`` TIMESTAMPTZ NOT NULL default now());
|
||||
* inserted rows round-trip: an omitted ``status`` defaults to 'draft'
|
||||
with NULL ``branch`` / ``commit_sha`` (the pre-push state) and both
|
||||
timestamps are stamped server-side; explicit push-state values
|
||||
round-trip verbatim;
|
||||
* two identical tokens are rejected by the unique index (the token is
|
||||
a unique handle — the share-token precedent, phase 51);
|
||||
* downgrade to 0010 → the table and index are gone (A13 — reversible),
|
||||
the rest of the schema (e.g. ``saved_chats.share_token``) survives;
|
||||
* upgrade back to 0011 → the table and the unique index are 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
|
||||
|
||||
import uuid
|
||||
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 _version(db: Session) -> str | None:
|
||||
return db.execute(text("SELECT version_num FROM alembic_version")).scalar()
|
||||
|
||||
|
||||
def _table_exists(db: Session, table: str) -> bool:
|
||||
count: Any = db.execute(
|
||||
text(
|
||||
"SELECT count(*) FROM information_schema.tables"
|
||||
" WHERE table_schema = 'public' AND table_name = :t"
|
||||
),
|
||||
{"t": table},
|
||||
).scalar()
|
||||
assert count is not None, "information_schema count must be an int"
|
||||
return int(count) == 1
|
||||
|
||||
|
||||
def _column(db: Session, table: str, column: str) -> tuple[Any, ...] | None:
|
||||
"""(data_type, is_nullable, column_default) for one table column."""
|
||||
row = db.execute(
|
||||
text(
|
||||
"SELECT data_type, is_nullable, column_default"
|
||||
" FROM information_schema.columns"
|
||||
" WHERE table_name = :t AND column_name = :c"
|
||||
),
|
||||
{"t": table, "c": column},
|
||||
).fetchone()
|
||||
return tuple(row) if row is not None else None
|
||||
|
||||
|
||||
def _unique_token_index(db: Session) -> int:
|
||||
"""1 iff ``ix_doc_drafts_token`` exists as a UNIQUE index."""
|
||||
count: Any = db.execute(
|
||||
text(
|
||||
"SELECT count(*) FROM pg_indexes"
|
||||
" WHERE tablename = 'doc_drafts'"
|
||||
" AND indexname = 'ix_doc_drafts_token'"
|
||||
),
|
||||
).scalar()
|
||||
assert count is not None, "pg_indexes count must be an int"
|
||||
is_unique: Any = db.execute(
|
||||
text(
|
||||
"SELECT indisunique FROM pg_index"
|
||||
" WHERE indexrelid = (SELECT oid FROM pg_class WHERE relname = 'ix_doc_drafts_token')"
|
||||
),
|
||||
).scalar()
|
||||
return int(count) if is_unique else 0
|
||||
|
||||
|
||||
def _insert(
|
||||
db: Session,
|
||||
*,
|
||||
token: uuid.UUID | None = None,
|
||||
status: str | None = None,
|
||||
branch: str | None = None,
|
||||
commit_sha: str | None = None,
|
||||
) -> uuid.UUID:
|
||||
"""Insert one doc_drafts row. ``status=None`` omits the column
|
||||
(server-default path); a ``token`` is always supplied — the
|
||||
migration carries no server default (the ORM/API supplies it)."""
|
||||
cols = ["id", "token", "title", "path", "body"]
|
||||
params: dict[str, Any] = {
|
||||
"t": "Mig 0011",
|
||||
"p": "docs/mig-0011.md",
|
||||
"b": "# Phase 59 migration probe\n",
|
||||
}
|
||||
if token is not None:
|
||||
params["tok"] = token
|
||||
if status is not None:
|
||||
cols.append("status")
|
||||
params["s"] = status
|
||||
if branch is not None:
|
||||
cols.append("branch")
|
||||
params["br"] = branch
|
||||
if commit_sha is not None:
|
||||
cols.append("commit_sha")
|
||||
params["sha"] = commit_sha
|
||||
sql = (
|
||||
f"INSERT INTO doc_drafts ({', '.join(cols)}) VALUES ("
|
||||
"gen_random_uuid(), :tok, :t, :p, :b"
|
||||
+ (", :s" if status is not None else "")
|
||||
+ (", :br" if branch is not None else "")
|
||||
+ (", :sha" if commit_sha is not None else "")
|
||||
+ ") RETURNING id"
|
||||
)
|
||||
draft_id: uuid.UUID = db.execute(text(sql), params).scalar_one()
|
||||
db.commit()
|
||||
return draft_id
|
||||
|
||||
|
||||
def _delete(db: Session, draft_id: uuid.UUID) -> None:
|
||||
db.execute(text("DELETE FROM doc_drafts WHERE id = :i"), {"i": draft_id})
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_upgrade_to_0011_adds_doc_drafts(db: Session, alembic: Config) -> None:
|
||||
"""Upgrade 0010 → 0011: the table + the unique token index exist
|
||||
with the full column contract; the table is absent at 0010."""
|
||||
command.downgrade(alembic, "0010") # start from the pre-0011 state
|
||||
assert _version(db) == "0010"
|
||||
assert not _table_exists(db, "doc_drafts"), "doc_drafts must be absent at 0010"
|
||||
assert _unique_token_index(db) == 0, "the token index must be absent at 0010"
|
||||
|
||||
command.upgrade(alembic, "0011")
|
||||
assert _version(db) == "0011", "alembic_version must be at 0011"
|
||||
assert _table_exists(db, "doc_drafts"), "doc_drafts must exist at 0011"
|
||||
|
||||
id_col = _column(db, "doc_drafts", "id")
|
||||
assert id_col is not None, "doc_drafts.id is missing"
|
||||
assert id_col[0] == "uuid", "doc_drafts.id must be UUID"
|
||||
assert id_col[1] == "NO", "doc_drafts.id must be NOT NULL (PK)"
|
||||
|
||||
token = _column(db, "doc_drafts", "token")
|
||||
assert token is not None, "doc_drafts.token is missing"
|
||||
assert token[0] == "uuid", "doc_drafts.token must be UUID"
|
||||
assert token[1] == "NO", "doc_drafts.token must be NOT NULL (no un-drafted state)"
|
||||
assert _unique_token_index(db) == 1, "the unique token index is missing"
|
||||
|
||||
for name in ("title", "path", "body"):
|
||||
col = _column(db, "doc_drafts", name)
|
||||
assert col is not None, f"doc_drafts.{name} is missing"
|
||||
assert col[0] == "text", f"doc_drafts.{name} must be TEXT"
|
||||
assert col[1] == "NO", f"doc_drafts.{name} must be NOT NULL"
|
||||
|
||||
status = _column(db, "doc_drafts", "status")
|
||||
assert status is not None, "doc_drafts.status is missing"
|
||||
assert status[0] == "text", "doc_drafts.status must be TEXT"
|
||||
assert status[1] == "NO", "doc_drafts.status must be NOT NULL"
|
||||
assert str(status[2]).startswith("'draft'"), (
|
||||
"doc_drafts.status must have server default 'draft'"
|
||||
)
|
||||
|
||||
for name in ("branch", "commit_sha"):
|
||||
col = _column(db, "doc_drafts", name)
|
||||
assert col is not None, f"doc_drafts.{name} is missing"
|
||||
assert col[0] == "text", f"doc_drafts.{name} must be TEXT"
|
||||
assert col[1] == "YES", f"doc_drafts.{name} must be NULL until pushed"
|
||||
|
||||
for name in ("created_at", "updated_at"):
|
||||
col = _column(db, "doc_drafts", name)
|
||||
assert col is not None, f"doc_drafts.{name} is missing"
|
||||
assert col[0] == "timestamp with time zone", (
|
||||
f"doc_drafts.{name} must be TIMESTAMPTZ"
|
||||
)
|
||||
assert col[1] == "NO", f"doc_drafts.{name} must be NOT NULL"
|
||||
assert str(col[2]).startswith("now("), (
|
||||
f"doc_drafts.{name} must have server default now()"
|
||||
)
|
||||
|
||||
|
||||
def test_inserted_rows_round_trip_the_pre_push_and_pushed_states(
|
||||
db: Session, alembic: Config
|
||||
) -> None:
|
||||
"""At 0011, an omitted status defaults to 'draft' with NULL
|
||||
branch/commit_sha (the pre-push state) and both timestamps are
|
||||
stamped server-side; explicit push-state values round-trip
|
||||
verbatim."""
|
||||
command.upgrade(alembic, "head")
|
||||
draft_token = uuid.uuid4()
|
||||
draft_id = _insert(db, token=draft_token)
|
||||
pushed_token = uuid.uuid4()
|
||||
pushed_id = _insert(
|
||||
db,
|
||||
token=pushed_token,
|
||||
status="pushed",
|
||||
branch="bor-docs",
|
||||
commit_sha="a" * 40,
|
||||
)
|
||||
try:
|
||||
row = db.execute(
|
||||
text(
|
||||
"SELECT token, status, branch, commit_sha, created_at, updated_at"
|
||||
" FROM doc_drafts WHERE id = :i"
|
||||
),
|
||||
{"i": draft_id},
|
||||
).fetchone()
|
||||
assert row is not None, "the draft row must exist"
|
||||
assert row[0] == draft_token, "the token must round-trip verbatim"
|
||||
assert row[1] == "draft", "an omitted status must default to 'draft'"
|
||||
assert row[2] is None and row[3] is None, (
|
||||
"branch/commit_sha must be NULL before the push endpoint runs"
|
||||
)
|
||||
assert row[4] is not None and row[5] is not None, (
|
||||
"created_at/updated_at must be stamped server-side"
|
||||
)
|
||||
|
||||
pushed = db.execute(
|
||||
text(
|
||||
"SELECT status, branch, commit_sha FROM doc_drafts WHERE id = :i"
|
||||
),
|
||||
{"i": pushed_id},
|
||||
).fetchone()
|
||||
assert pushed is not None, "the pushed row must exist"
|
||||
assert tuple(pushed) == ("pushed", "bor-docs", "a" * 40), (
|
||||
"explicit push-state values must round-trip verbatim"
|
||||
)
|
||||
finally:
|
||||
_delete(db, draft_id)
|
||||
_delete(db, pushed_id)
|
||||
|
||||
|
||||
def test_unique_index_rejects_duplicate_tokens(db: Session, alembic: Config) -> None:
|
||||
"""Two identical tokens are rejected by the unique index — the
|
||||
token is the unique URL credential (the share-token precedent,
|
||||
phase 51); a distinct token still lands."""
|
||||
command.upgrade(alembic, "head")
|
||||
dup_token = uuid.uuid4()
|
||||
first_id = _insert(db, token=dup_token)
|
||||
other_id: uuid.UUID | None = None
|
||||
try:
|
||||
try:
|
||||
_insert(db, token=dup_token)
|
||||
except IntegrityError:
|
||||
db.rollback() # the aborted transaction must not leak
|
||||
else:
|
||||
pytest.fail("a duplicate doc_drafts.token must be rejected")
|
||||
|
||||
# A different token is fine — only the exact duplicate is unique.
|
||||
other_id = _insert(db, token=uuid.uuid4())
|
||||
finally:
|
||||
_delete(db, first_id)
|
||||
if other_id is not None:
|
||||
_delete(db, other_id)
|
||||
|
||||
|
||||
def test_downgrade_to_0010_drops_the_table(db: Session, alembic: Config) -> None:
|
||||
"""Downgrade to 0010: the table and the unique index are gone
|
||||
(A13 — reversible) while the rest of the schema survives."""
|
||||
command.downgrade(alembic, "0010")
|
||||
assert _version(db) == "0010"
|
||||
assert not _table_exists(db, "doc_drafts"), "doc_drafts must be dropped"
|
||||
assert _unique_token_index(db) == 0, "the token index must be dropped"
|
||||
|
||||
token_col = _column(db, "saved_chats", "share_token")
|
||||
assert token_col is not None and token_col[0] == "uuid", (
|
||||
"saved_chats.share_token must survive the downgrade"
|
||||
)
|
||||
meta = _column(db, "sources_meta", "version")
|
||||
assert meta is not None and meta[0] == "integer", (
|
||||
"sources_meta.version must survive the downgrade"
|
||||
)
|
||||
|
||||
|
||||
def test_upgrade_round_trip_restores_the_table(db: Session, alembic: Config) -> None:
|
||||
"""Downgrade to 0010, then upgrade back to 0011: the table and the
|
||||
unique index are back."""
|
||||
command.downgrade(alembic, "0010")
|
||||
command.upgrade(alembic, "0011")
|
||||
assert _version(db) == "0011", "round-trip upgrade must land at 0011"
|
||||
|
||||
assert _table_exists(db, "doc_drafts"), "doc_drafts must be back"
|
||||
assert _unique_token_index(db) == 1, "the unique token index must be back"
|
||||
|
||||
status = _column(db, "doc_drafts", "status")
|
||||
assert status is not None and status[1] == "NO", (
|
||||
"status must be TEXT NOT NULL after the round-trip"
|
||||
)
|
||||
assert str(status[2]).startswith("'draft'"), (
|
||||
"status must default to 'draft' after the round-trip"
|
||||
)
|
||||
@@ -212,6 +212,7 @@ def test_html_pages_include_history() -> None:
|
||||
"/git-sources.html",
|
||||
"/history.html",
|
||||
"/shared.html", # phase 51: the shared page's static path
|
||||
"/doc-edit.html", # phase 59: the doc edit screen (task 06)
|
||||
):
|
||||
assert path in caching.HTML_PAGES, f"{path} must be in HTML_PAGES"
|
||||
|
||||
|
||||
@@ -279,3 +279,121 @@ def test_effective_api_key_fallback(monkeypatch) -> None:
|
||||
monkeypatch.setenv("AIPI_KEY", "sk-from-env")
|
||||
s2 = _settings()
|
||||
assert s2.effective_api_key == "sk-from-env"
|
||||
|
||||
|
||||
# --- Docs push (phase 59) ---
|
||||
|
||||
|
||||
def test_docs_push_defaults_are_inert() -> None:
|
||||
"""Phase 59, D3: no docs repo by default — the feature is
|
||||
inert-by-default (button hidden, push endpoint 409s — the
|
||||
optional-feature pattern of the git-sources env fallback), and the
|
||||
branch/base defaults + raw work-dir string are in place."""
|
||||
s = _settings()
|
||||
assert s.docs_repo == ""
|
||||
assert s.docs_configured is False
|
||||
assert s.docs_branch == "bor-docs"
|
||||
assert s.docs_base_branch == "main"
|
||||
# Raw string on purpose — Path.expanduser() is applied by the push
|
||||
# service, not the setting (the sources_dir/upload_dir convention).
|
||||
assert s.docs_work_dir == "~/bor-docs"
|
||||
|
||||
|
||||
def test_docs_repo_set_is_configured(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""A non-empty ``BOR_DOCS_REPO`` turns the feature on — a URL or a
|
||||
local path (D3: generic remote, no scheme parsing here)."""
|
||||
for repo in ("/path/to/docs-repo", "https://git.example.com/docs.git"):
|
||||
monkeypatch.setenv("BOR_DOCS_REPO", repo)
|
||||
s = _settings()
|
||||
assert s.docs_configured is True
|
||||
assert s.docs_repo == repo
|
||||
# Whitespace-only behaves like empty: still inert.
|
||||
monkeypatch.setenv("BOR_DOCS_REPO", " ")
|
||||
assert _settings().docs_configured is False
|
||||
|
||||
|
||||
def test_docs_branch_env_override(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("BOR_DOCS_BRANCH", raising=False)
|
||||
monkeypatch.delenv("BOR_DOCS_BASE_BRANCH", raising=False)
|
||||
assert _settings().docs_branch == "bor-docs"
|
||||
assert _settings().docs_base_branch == "main"
|
||||
monkeypatch.setenv("BOR_DOCS_BRANCH", "docs-pr")
|
||||
monkeypatch.setenv("BOR_DOCS_BASE_BRANCH", "master")
|
||||
s = _settings()
|
||||
assert s.docs_branch == "docs-pr"
|
||||
assert s.docs_base_branch == "master"
|
||||
|
||||
|
||||
def test_docs_work_dir_env_override_is_raw_string(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("BOR_DOCS_WORK_DIR", "/data/bor/docs")
|
||||
s = _settings()
|
||||
assert s.docs_work_dir == "/data/bor/docs"
|
||||
|
||||
|
||||
def test_docs_branch_whitespace_fails_loudly_when_repo_set(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A whitespace-bearing branch would corrupt a ``git checkout``
|
||||
argument — fail loud at startup, naming the field (the
|
||||
``agent_max_rounds`` pattern)."""
|
||||
monkeypatch.setenv("BOR_DOCS_REPO", "/path/to/docs-repo")
|
||||
monkeypatch.setenv("BOR_DOCS_BRANCH", "bor docs")
|
||||
with pytest.raises(ValidationError, match="docs_branch"):
|
||||
_settings()
|
||||
|
||||
|
||||
def test_docs_branch_dotdot_fails_loudly_when_repo_set(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""``..`` is a path-traversal token, never part of a branch name.
|
||||
A blank branch is rejected too (empty while a repo is set)."""
|
||||
monkeypatch.setenv("BOR_DOCS_REPO", "/path/to/docs-repo")
|
||||
monkeypatch.setenv("BOR_DOCS_BRANCH", "a..b")
|
||||
with pytest.raises(ValidationError, match="docs_branch"):
|
||||
_settings()
|
||||
monkeypatch.setenv("BOR_DOCS_BRANCH", " ")
|
||||
with pytest.raises(ValidationError, match="docs_branch"):
|
||||
_settings()
|
||||
|
||||
|
||||
def test_docs_base_branch_invalid_fails_loudly_naming_field(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The base branch gets the same token shape check — the error
|
||||
names ``docs_base_branch``, not the sibling field."""
|
||||
monkeypatch.setenv("BOR_DOCS_REPO", "/path/to/docs-repo")
|
||||
monkeypatch.setenv("BOR_DOCS_BASE_BRANCH", "bad branch")
|
||||
with pytest.raises(ValidationError, match="docs_base_branch"):
|
||||
_settings()
|
||||
monkeypatch.setenv("BOR_DOCS_BASE_BRANCH", "a..b")
|
||||
with pytest.raises(ValidationError, match="docs_base_branch"):
|
||||
_settings()
|
||||
|
||||
|
||||
def test_docs_branchs_valid_when_repo_set(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Repo set + well-formed branch tokens boot cleanly and the
|
||||
feature is configured (dash/dot/slash branch names are legal git
|
||||
refs and stay accepted)."""
|
||||
monkeypatch.setenv("BOR_DOCS_REPO", "/path/to/docs-repo")
|
||||
s = _settings() # defaults bor-docs / main
|
||||
assert s.docs_configured is True
|
||||
monkeypatch.setenv("BOR_DOCS_BRANCH", "feature/docs-update")
|
||||
monkeypatch.setenv("BOR_DOCS_BASE_BRANCH", "develop")
|
||||
s2 = _settings()
|
||||
assert s2.docs_configured is True
|
||||
assert s2.docs_branch == "feature/docs-update"
|
||||
assert s2.docs_base_branch == "develop"
|
||||
|
||||
|
||||
def test_docs_branchs_garbage_ignored_when_repo_unset(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""All-or-nothing: while the repo is empty the feature is inert, so
|
||||
the (ignored) branch values must NOT block startup — only a
|
||||
configured repo makes the shape check apply."""
|
||||
monkeypatch.delenv("BOR_DOCS_REPO", raising=False)
|
||||
monkeypatch.setenv("BOR_DOCS_BRANCH", "bor docs..")
|
||||
monkeypatch.setenv("BOR_DOCS_BASE_BRANCH", "..")
|
||||
s = _settings()
|
||||
assert s.docs_configured is False
|
||||
assert s.docs_branch == "bor docs.." # stored verbatim, never used
|
||||
|
||||
@@ -0,0 +1,545 @@
|
||||
"""Unit: the phase-59 task-06 doc edit screen (``/doc-edit.html``).
|
||||
|
||||
No Python logic exists for this task — the behavior lives in
|
||||
``frontend/doc-edit.html`` + ``frontend/assets/doc-edit.js`` +
|
||||
``styles.css``, and it is E2E-gated by the story suite (task 07). Like
|
||||
the other frontend-adjacent unit files (``test_history_page.py``,
|
||||
``test_save_as_doc_button.py``), this module pins the HTML/JS/CSS
|
||||
markers the edit loop depends on, so a silent regression is caught
|
||||
without a browser:
|
||||
|
||||
* the house shell (AGENTS.md rule 5 + the login.html/shared.html
|
||||
minimal-flow-page lineage): skip-link, the SLIM header (brand +
|
||||
"Back to chat" — no nav), the 46rem base column (hard-coded — a form
|
||||
column, NOT ``--chat-column``), the ``container`` frame;
|
||||
* the form contract: ``#draft-title`` / ``#draft-path`` /
|
||||
``#draft-body`` with visible labels, ``#push-doc-btn`` (the exact
|
||||
"Push to docs branch" copy) + the back link, ``#push-status``
|
||||
(``role="status" aria-live="polite"``) and the hidden ``#push-error``
|
||||
(``role="alert"``);
|
||||
* the admin gate — the ``sources-gate`` pattern, ship-hidden, with the
|
||||
no-JS ``?next=`` fallback (the page is static; the API is the
|
||||
authority — the draft endpoints are admin-only regardless);
|
||||
* the JS: the whoami gate (anonymous branch makes NO ``/api/doc-drafts``
|
||||
call), the token handling (missing → "No draft specified.",
|
||||
non-uuid → "Draft not found." with no fetch), the three API paths
|
||||
(GET draft / PUT edits / POST push — the PUT runs BEFORE the push:
|
||||
the endpoint commits the row, so unsaved edits would push stale
|
||||
text), the §7.4 never-stale lifecycle (disable + "Pushing…",
|
||||
re-enable in the finally), the success line
|
||||
(``Pushed to <branch> — commit <sha7>.``), the failure banner
|
||||
(git's stderr trimmed to its first meaningful lines, fields
|
||||
preserved), and VALUES-not-innerHTML everywhere.
|
||||
|
||||
The Containerfile stage-1 coverage (doc-edit.html copied, doc-edit.js
|
||||
bundled) and the cache-busting registration (``/doc-edit.html`` in
|
||||
``HTML_PAGES``) are pinned by ``test_containerfile_assets.py`` /
|
||||
``test_caching.py``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||||
ASSETS = FRONTEND / "assets"
|
||||
DOC_EDIT_HTML = FRONTEND / "doc-edit.html"
|
||||
DOC_EDIT_JS = ASSETS / "doc-edit.js"
|
||||
STYLES_CSS = ASSETS / "styles.css"
|
||||
|
||||
|
||||
def _html() -> str:
|
||||
assert DOC_EDIT_HTML.is_file(), "frontend/doc-edit.html is missing"
|
||||
return DOC_EDIT_HTML.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _js() -> str:
|
||||
assert DOC_EDIT_JS.is_file(), "frontend/assets/doc-edit.js is missing"
|
||||
return DOC_EDIT_JS.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _css() -> str:
|
||||
return STYLES_CSS.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _fn(js: str, name: str) -> str:
|
||||
"""The source of a top-level ``function <name>(...)`` (to its close)."""
|
||||
start = js.find(f"function {name}(")
|
||||
assert start != -1, f"{name}() must exist in doc-edit.js"
|
||||
return js[start : js.find("\n}\n", start) + 4]
|
||||
|
||||
|
||||
# ---------- the house shell (AGENTS.md rule 5) ----------
|
||||
|
||||
|
||||
def test_page_scaffold_slim_header_and_landmarks() -> None:
|
||||
"""The minimal-flow-page scaffold (the login.html/shared.html
|
||||
lineage): skip-link, the SLIM header (brand + the "Back to chat"
|
||||
link to / — and NO nav: this is a flow page, not one of the app's
|
||||
pages), ``<main id="main" class="app-main" tabindex="-1">`` with
|
||||
the ``container`` frame, and the house footer."""
|
||||
html = _html()
|
||||
assert '<a class="skip-link" href="#main">Skip to content</a>' in html
|
||||
assert 'class="app-header"' in html
|
||||
# The slim header: the brand + the back link.
|
||||
assert '<span class="brand-text">Brain of <strong>Reese</strong></span>' in html
|
||||
back = re.search(r'<a[^>]*class="doc-edit-back"[^>]*href="/"[^>]*>', html)
|
||||
assert back, "the header must carry the 'Back to chat' link to /"
|
||||
assert "<span>Back to chat</span>" in html
|
||||
# And NO nav — the flow-page lineage (no hamburger, no app-nav).
|
||||
assert 'id="app-nav"' not in html, "the slim header ships no nav"
|
||||
assert 'id="nav-toggle"' not in html, "the slim header ships no hamburger"
|
||||
assert "<main id=\"main\" class=\"app-main\" tabindex=\"-1\">" in html
|
||||
assert '<div class="container doc-edit-shell">' in html
|
||||
assert 'class="app-footer"' in html
|
||||
|
||||
|
||||
def test_page_title_and_description() -> None:
|
||||
"""The page's identity: the house title shape (<name> · Brain of
|
||||
Reese) + a description naming the admin-only flow."""
|
||||
html = _html()
|
||||
assert "<title>Edit doc · Brain of Reese</title>" in html
|
||||
desc = re.search(r'<meta name="description" content="([^"]+)"', html)
|
||||
assert desc, "the page must carry a meta description"
|
||||
assert "admin" in desc.group(1).lower()
|
||||
|
||||
|
||||
# ---------- the form contract ----------
|
||||
|
||||
|
||||
def test_form_fields_have_labels_and_ids() -> None:
|
||||
"""The three fields (task 06): #draft-title (text), #draft-path
|
||||
(text), #draft-body (textarea) — each with a VISIBLE
|
||||
``<label for=…>`` (WCAG input-label rule), the text inputs
|
||||
``required`` (the browser's native prompt is the first line of
|
||||
sanity), the body a <textarea>."""
|
||||
html = _html()
|
||||
for field_id, tag in (
|
||||
("draft-title", "input"),
|
||||
("draft-path", "input"),
|
||||
("draft-body", "textarea"),
|
||||
):
|
||||
assert f'<label for="{field_id}">' in html, (
|
||||
f"#{field_id} needs a visible label"
|
||||
)
|
||||
field = re.search(rf"<{tag}[^>]*id=\"{field_id}\"[^>]*>", html)
|
||||
assert field, f"#{field_id} is missing"
|
||||
title = re.search(r"<input[^>]*id=\"draft-title\"[^>]*>", html)
|
||||
path = re.search(r"<input[^>]*id=\"draft-path\"[^>]*>", html)
|
||||
body = re.search(r"<textarea[^>]*id=\"draft-body\"[^>]*>", html)
|
||||
assert title and path and body, "the draft field tags are missing"
|
||||
title, path, body = title.group(0), path.group(0), body.group(0)
|
||||
for f in (title, path, body):
|
||||
assert "required" in f, "the native `required` is the first line"
|
||||
assert "type=\"text\"" in title and "type=\"text\"" in path
|
||||
|
||||
|
||||
def test_push_button_and_back_link_actions() -> None:
|
||||
"""The actions (task 06): #push-doc-btn — the primary, exact copy
|
||||
"Push to docs branch" — and the back link to / (the form's second
|
||||
action; the header carries its own copy)."""
|
||||
html = _html()
|
||||
btn = re.search(r"<button[^>]*id=\"push-doc-btn\"[^>]*>", html)
|
||||
assert btn, "#push-doc-btn is missing"
|
||||
assert "type=\"submit\"" in btn.group(0), (
|
||||
"the push button submits the form (the handler preventDefaults)"
|
||||
)
|
||||
assert ">Push to docs branch</button>" in html, (
|
||||
"the exact house copy: 'Push to docs branch'"
|
||||
)
|
||||
# A back link inside the actions row (href="/").
|
||||
actions = html[html.find('class="doc-edit-actions"'):]
|
||||
actions = actions[: actions.find("</form>")]
|
||||
assert re.search(r'<a[^>]*class="doc-edit-back"[^>]*href="/"[^>]*>', actions), (
|
||||
"the actions row carries its own back link to /"
|
||||
)
|
||||
|
||||
|
||||
def test_feedback_live_region_and_error_banner() -> None:
|
||||
"""The "never stale" feedback contract (phase 55 convention,
|
||||
task 06): #push-status is the polite live region
|
||||
(role="status" aria-live="polite"); #push-error is the alert
|
||||
banner — SHIPS hidden (role="alert")."""
|
||||
html = _html()
|
||||
status = re.search(r'<[a-z]+[^>]*id="push-status"[^>]*>', html)
|
||||
assert status, "#push-status is missing"
|
||||
assert 'role="status"' in status.group(0)
|
||||
assert 'aria-live="polite"' in status.group(0)
|
||||
error = re.search(r'<[a-z]+[^>]*id="push-error"[^>]*>', html)
|
||||
assert error, "#push-error is missing"
|
||||
assert 'role="alert"' in error.group(0)
|
||||
assert "hidden" in error.group(0), "the error banner ships hidden"
|
||||
|
||||
|
||||
# ---------- the admin gate (the sources-gate pattern) ----------
|
||||
|
||||
|
||||
def test_admin_gate_ships_hidden_with_no_js_fallback() -> None:
|
||||
"""The gate: the EXACT .sources-gate pattern (phase 16/35/50),
|
||||
ship-hidden (the admin never sees it; the content div ships hidden
|
||||
too — anonymous-safe), the labelled h2, and the Sign in link whose
|
||||
static ?next= returns the admin to THIS page after login (the
|
||||
no-JS fallback)."""
|
||||
html = _html()
|
||||
gate = re.search(r'<section[^>]*class="sources-gate"[^>]*id="doc-edit-gate"[^>]*>', html)
|
||||
assert gate, "the #doc-edit-gate section (sources-gate pattern) is missing"
|
||||
assert "hidden" in gate.group(0), "the gate ships hidden"
|
||||
assert 'aria-labelledby="doc-edit-gate-title"' in gate.group(0)
|
||||
assert '<h2 id="doc-edit-gate-title">' in html
|
||||
assert '<a class="sources-gate-link" href="/login.html?next=/doc-edit.html">Sign in</a>' in html
|
||||
# The content ships hidden too (the gate is what anonymous sees).
|
||||
content = re.search(r'<div[^>]*id="doc-edit-content"[^>]*>', html)
|
||||
assert content and "hidden" in content.group(0), (
|
||||
"#doc-edit-content must ship hidden (anonymous-safe)"
|
||||
)
|
||||
|
||||
|
||||
# ---------- scripts + no CDN ----------
|
||||
|
||||
|
||||
def test_script_load_order_and_no_cdn() -> None:
|
||||
"""The house script order: brand.js (classic) FIRST, the doc-edit.js
|
||||
module second; NO direct header.js <script> tag (single-evaluation
|
||||
design — doc-edit.js imports it relatively); no external
|
||||
src=/href= (AGENTS.md rule 6 — No CDN)."""
|
||||
html = _html()
|
||||
srcs = re.findall(r'<script[^>]*src="([^"]+)"', html)
|
||||
assert srcs == ["assets/brand.js", "/assets/doc-edit.js"], (
|
||||
f"doc-edit.html must load brand.js (classic, first) + the "
|
||||
f"doc-edit.js module, got {srcs}"
|
||||
)
|
||||
js = _js()
|
||||
assert 'from "./header.js"' in js, (
|
||||
"doc-edit.js must import the shared header module relatively"
|
||||
)
|
||||
assert '"/assets/header.js"' not in js
|
||||
assert 'src="http' not in html and 'href="http' not in html, (
|
||||
"no CDN: every asset is local (AGENTS.md rule 6)"
|
||||
)
|
||||
|
||||
|
||||
# ---------- boot: the whoami gate ----------
|
||||
|
||||
|
||||
def test_anonymous_boot_makes_no_drafts_request() -> None:
|
||||
"""The whoami gate in the boot IIFE: ``fetchIsAdmin()`` (the
|
||||
header.js cached whoami — the single /api/whoami call site) decides
|
||||
the gate. Anonymous: the gate shows, the content stays hidden, a
|
||||
bare return — and NO /api/doc-drafts call on the wire (the draft
|
||||
API is admin-only regardless; the story E2E pins the request
|
||||
log). Only the admin path reaches the token read + loadDraft."""
|
||||
js = _js()
|
||||
assert "fetchIsAdmin" in js, "the gate must run on the cached whoami"
|
||||
boot = js[js.find("(async () => {"):]
|
||||
assert boot, "the boot IIFE must exist"
|
||||
gate_i = boot.find("const admin = await fetchIsAdmin();")
|
||||
assert gate_i != -1, "boot must await fetchIsAdmin() first"
|
||||
branch = boot[gate_i : boot.find("return;", gate_i)]
|
||||
assert "fetch(" not in branch, (
|
||||
"the anonymous branch must not fetch anything (no draft leak)"
|
||||
)
|
||||
assert "gateEl.hidden = false" in branch
|
||||
assert "contentEl.hidden = true" in branch
|
||||
# The admin path: the gate hides, the content reveals, the token
|
||||
# is read, and only THEN does the draft load.
|
||||
after = boot[boot.find("return;", gate_i):]
|
||||
assert "gateEl.hidden = true" in after
|
||||
assert "contentEl.hidden = false" in after
|
||||
token_i = after.find('new URLSearchParams(window.location.search).get("draft")')
|
||||
assert token_i != -1, "boot must read ?draft=<token>"
|
||||
assert after.find("await loadDraft(token)") > token_i
|
||||
|
||||
|
||||
def test_token_missing_and_malformed_copy() -> None:
|
||||
"""The token handling: missing → the error banner "No draft
|
||||
specified."; a non-uuid token → "Draft not found." with NO fetch
|
||||
(the shared.js malformed-token precedent — a 422 validation line
|
||||
is framework noise, not a house message)."""
|
||||
js = _js()
|
||||
boot = js[js.find("(async () => {"):]
|
||||
missing_i = boot.find('showError("No draft specified.")')
|
||||
assert missing_i != -1, "the missing-token banner copy is pinned"
|
||||
# The uuid shape check gates the fetch (malformed → no request).
|
||||
malformed_i = boot.find("UUID_RE.test(token)")
|
||||
assert malformed_i != -1, "the uuid shape check must gate the fetch"
|
||||
after_malformed = boot[malformed_i : malformed_i + 300]
|
||||
assert 'showError("Draft not found.")' in after_malformed
|
||||
assert "fetch(" not in after_malformed, ("a malformed token must not fetch")
|
||||
# The regex is the 8-4-4-4-12 uuid shape (case-insensitive).
|
||||
assert "/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i" in js
|
||||
# And draftToken (the push's credential) is set only after the
|
||||
# checks — the load follows.
|
||||
assert boot.find("draftToken = token;") > malformed_i
|
||||
assert boot.find("await loadDraft(token)") > boot.find("draftToken = token;")
|
||||
|
||||
|
||||
# ---------- the three API paths ----------
|
||||
|
||||
|
||||
def test_the_three_api_paths() -> None:
|
||||
"""The edit loop's three draft API paths (task 06): GET
|
||||
/api/doc-drafts/<token> (load — in loadDraft), PUT
|
||||
/api/doc-drafts/<token> (persist the edits) and POST
|
||||
/api/doc-drafts/<token>/push (the single mutation). The PUT runs
|
||||
BEFORE the push: the push endpoint commits the ROW's
|
||||
title/path/body, so an unsaved edit would push stale text."""
|
||||
js = _js()
|
||||
load = _fn(js, "loadDraft")
|
||||
assert 'fetch(`/api/doc-drafts/${token}`)' in load, (
|
||||
"loadDraft must GET the draft by token"
|
||||
)
|
||||
assert "push" not in load.lower().replace("pushing", ""), (
|
||||
"loadDraft must not push (it only loads)"
|
||||
)
|
||||
push_fn = js[js.find("function wirePush() {"):]
|
||||
put_i = push_fn.find('method: "PUT"')
|
||||
post_i = push_fn.find("/push")
|
||||
assert put_i != -1 and post_i != -1, "the PUT + the POST /push must both exist"
|
||||
assert put_i < post_i, "the PUT (persist edits) must run BEFORE the push"
|
||||
assert 'fetch(`/api/doc-drafts/${draftToken}/push`, {' in push_fn, (
|
||||
"the push endpoint is POST /api/doc-drafts/<token>/push"
|
||||
)
|
||||
# Exactly one call per path — no duplicate fetch sites.
|
||||
assert js.count("doc-drafts") >= 3
|
||||
|
||||
|
||||
def test_load_fill_is_values_not_innerhtml() -> None:
|
||||
"""The 200 body fills the three fields with VALUES
|
||||
(``.value`` = textContent discipline) — NEVER innerHTML: the body
|
||||
is user-derived markdown, and the title/path may contain anything
|
||||
but markup. The whole file builds no HTML at all (the page markup
|
||||
is static; JS only reads/sets values and hidden flags)."""
|
||||
js = _js()
|
||||
load = _fn(js, "loadDraft")
|
||||
assert "titleInput.value = draft.title" in load
|
||||
assert "pathInput.value = draft.path" in load
|
||||
assert "bodyInput.value = draft.body" in load
|
||||
assert "innerHTML" not in js, "doc-edit.js must never build HTML"
|
||||
|
||||
|
||||
def test_load_outcome_copy() -> None:
|
||||
"""loadDraft's failure lines: 404 → "Draft not found." (no
|
||||
enumeration — one message for every unknown token), other non-2xx
|
||||
→ the server's detail (422 shape-aware), a network failure → the
|
||||
fixed one-line copy."""
|
||||
js = _js()
|
||||
load = _fn(js, "loadDraft")
|
||||
assert 'showError("Draft not found.")' in load
|
||||
assert "r.status === 404" in load
|
||||
assert "apiDetail(" in load, "non-2xx must surface the server detail"
|
||||
assert "is the app running?" in load, "the network-failure line"
|
||||
# The 404 check runs before the generic non-2xx arm.
|
||||
assert load.find("r.status === 404") < load.find("if (!r.ok)")
|
||||
|
||||
|
||||
# ---------- push: the never-stale lifecycle ----------
|
||||
|
||||
|
||||
def test_push_sanity_checks_before_any_request() -> None:
|
||||
"""The client-side sanity (the server is the authority — it
|
||||
re-runs the guard-rails): non-empty title, non-empty body, no
|
||||
".." in the path. Each violation lands the error banner, focuses
|
||||
the offending field, and returns BEFORE any fetch — and without a
|
||||
token the banner says "No draft specified." (no fetch)."""
|
||||
js = _js()
|
||||
fn = js[js.find("function wirePush() {"):]
|
||||
title_i = fn.find('showError("Enter a title for the doc.")')
|
||||
body_i = fn.find('showError("The doc body must not be empty.")')
|
||||
path_i = fn.find("path.includes(\"..\")")
|
||||
assert title_i != -1 and body_i != -1 and path_i != -1, (
|
||||
"the three sanity checks must exist"
|
||||
)
|
||||
assert title_i < body_i < path_i, "title, body, path — in field order"
|
||||
# Each violation focuses its field (keyboard a11y).
|
||||
assert "titleInput.focus()" in fn
|
||||
assert "bodyInput.focus()" in fn
|
||||
assert "pathInput.focus()" in fn
|
||||
# No token → the banner, no fetch (the first fetch comes later).
|
||||
notoken_i = fn.find('showError("No draft specified.")')
|
||||
first_fetch = fn.find("await fetch(")
|
||||
assert -1 < notoken_i < first_fetch
|
||||
|
||||
|
||||
def test_push_disables_relabels_and_reenables() -> None:
|
||||
"""The §7.4 never-stale lifecycle: the button disables +
|
||||
relabels "Pushing…" AND the live region says "Pushing…" while the
|
||||
request is out; the finally re-enables the button with its idle
|
||||
label (IDLE_LABEL = the exact static copy) on EVERY outcome —
|
||||
success OR failure, success AND failure."""
|
||||
js = _js()
|
||||
fn = js[js.find("function wirePush() {"):]
|
||||
disable_i = fn.find("pushBtn.disabled = true")
|
||||
relabel_i = fn.find('pushBtn.textContent = "Pushing…"')
|
||||
status_i = fn.find('setStatus("Pushing…")')
|
||||
assert disable_i != -1 and relabel_i != -1 and status_i != -1, (
|
||||
"disable + relabel + status before the requests"
|
||||
)
|
||||
fetch_i = fn.find("await fetch(")
|
||||
assert -1 < status_i < fetch_i, "the status line precedes the first request"
|
||||
finally_i = fn.find("} finally {")
|
||||
assert finally_i != -1, "the finally block is the never-stale guarantee"
|
||||
after_finally = fn[finally_i:]
|
||||
assert "pushBtn.disabled = false" in after_finally
|
||||
assert "pushBtn.textContent = IDLE_LABEL" in after_finally
|
||||
# The idle label IS the static button copy (a mismatch would
|
||||
# relabel the button into an unknown state on success).
|
||||
assert 'const IDLE_LABEL = "Push to docs branch";' in js
|
||||
|
||||
|
||||
def test_push_success_line_branch_and_sha7() -> None:
|
||||
"""The 200 outcome: the live region reads
|
||||
`Pushed to <branch> — commit <sha7>.` — the branch from the API,
|
||||
the commit sha TRUNCATED to its first seven chars for display (the
|
||||
full value stays in the API/draft row), the exact em-dash shape.
|
||||
The button re-enables (a re-push after further edits is a NEW
|
||||
commit — the D3 ASSUMPTION)."""
|
||||
js = _js()
|
||||
fn = js[js.find("function wirePush() {"):]
|
||||
assert (
|
||||
"`Pushed to ${pushed.branch} — commit ${String(pushed.commit_sha).slice(0, 7)}.`"
|
||||
in fn
|
||||
), "the success line is 'Pushed to <branch> — commit <sha7>.'"
|
||||
# The success line is set AFTER the push response is read.
|
||||
json_i = fn.find("await r.json()")
|
||||
ok_i = fn.find('`Pushed to ${pushed.branch}')
|
||||
assert -1 < json_i < ok_i
|
||||
|
||||
|
||||
def test_push_failure_banner_trims_git_detail_and_keeps_fields() -> None:
|
||||
"""The failure outcome: the #push-error banner with the API's
|
||||
detail — for a git 502 that is git's stderr, trimmed to its first
|
||||
meaningful lines (trimGitDetail: blank lines + the "hint:" chatter
|
||||
dropped, at most three lines, single-line details untouched) — the
|
||||
fields are PRESERVED (no input is cleared anywhere in the file)
|
||||
and the stale success line is cleared so only the error claims the
|
||||
outcome. Network failure → the fixed one-line copy."""
|
||||
js = _js()
|
||||
fn = js[js.find("function wirePush() {"):]
|
||||
assert "trimGitDetail(" in fn, "the failure detail must pass the trimmer"
|
||||
assert "apiDetail(" in fn, "the detail must be the API's (422-shape-aware)"
|
||||
# No field is ever cleared: the user's edits survive a failed push.
|
||||
for field in ('titleInput.value = ""', 'pathInput.value = ""',
|
||||
'bodyInput.value = ""', "titleInput.value=''",
|
||||
"pathInput.value=''", "bodyInput.value=''"):
|
||||
assert field not in js, f"a failed push must keep the edits, not {field!r}"
|
||||
assert "is the app running?" in fn, "the network-failure line"
|
||||
# The stale success line is cleared on failure (one claim at a
|
||||
# time — the PUT-failure arm clears it too).
|
||||
fail_i = fn.find("trimGitDetail(")
|
||||
assert fn.rfind('setStatus("")', 0, fail_i) > 0, (
|
||||
"a failed push clears the status line before the banner"
|
||||
)
|
||||
put_fail_i = fn.find('showError(await apiDetail(put')
|
||||
assert put_fail_i != -1
|
||||
assert fn.rfind('setStatus("")', 0, put_fail_i) > 0, (
|
||||
"a failed PUT also clears the stale status line"
|
||||
)
|
||||
|
||||
trim = _fn(js, "trimGitDetail")
|
||||
assert 'l.startsWith("hint:")' in trim, "the 'hint:' chatter is dropped"
|
||||
assert "slice(0, 3)" in trim, "at most three meaningful lines"
|
||||
assert "filter(" in trim and ".trim()" in trim
|
||||
|
||||
|
||||
def test_trim_git_detail_behavior_is_pinned_by_the_markers() -> None:
|
||||
"""The trimmer's contract in one place: non-empty lines that are
|
||||
not hint: lines, up to three, space-joined, with a fallback for an
|
||||
all-hint/empty detail (the banner must never be blank)."""
|
||||
js = _js()
|
||||
trim = _fn(js, "trimGitDetail")
|
||||
assert "split(\"\\n\")" in trim
|
||||
assert 'join(" ")' in trim
|
||||
assert '|| "The push failed."' in trim, "the empty-detail fallback"
|
||||
|
||||
|
||||
# ---------- styles.css: the new classes ----------
|
||||
|
||||
|
||||
def test_doc_edit_shell_is_the_hardcoded_46rem_column() -> None:
|
||||
""".doc-edit-shell: the 46rem base column — HARD-CODED 46rem (a
|
||||
form column, not a reading column — it must NOT ride
|
||||
--chat-column, so phase 58's wide-desktop doubling never stretches
|
||||
the form), centered, a flex column on the container frame."""
|
||||
css = _css()
|
||||
block = re.search(r"\.doc-edit-shell \{([\s\S]*?)\n\}", css)
|
||||
assert block, "styles.css must style .doc-edit-shell"
|
||||
body = block.group(1)
|
||||
assert "max-width: 46rem" in body, "the 46rem base column (hard-coded)"
|
||||
assert "--chat-column" not in body, (
|
||||
"the form column does not ride --chat-column (phase 58 must "
|
||||
"not stretch it)"
|
||||
)
|
||||
assert "margin-inline: auto" in body
|
||||
assert "flex-direction: column" in body
|
||||
|
||||
|
||||
def test_back_link_and_push_button_css() -> None:
|
||||
""".doc-edit-back: the ghost language (>=44px target, --line
|
||||
border, ink-soft on the --surface bar), pushed right
|
||||
(margin-left: auto); #push-doc-btn: the brand primary (dark ink on
|
||||
brand 5.2:1 — never white on brand), >=44px, a :disabled state
|
||||
(the "Pushing…" affordance)."""
|
||||
css = _css()
|
||||
back = re.search(r"\.doc-edit-back \{([\s\S]*?)\n\}", css)
|
||||
assert back, "styles.css must style .doc-edit-back"
|
||||
bbody = back.group(1)
|
||||
assert "min-height: 44px" in bbody
|
||||
assert "border: 1px solid var(--line)" in bbody
|
||||
assert "var(--ink-soft)" in bbody
|
||||
assert "margin-left: auto" in bbody
|
||||
btn = re.search(r"#push-doc-btn \{([\s\S]*?)\n\}", css)
|
||||
assert btn, "styles.css must style #push-doc-btn"
|
||||
tbody = btn.group(1)
|
||||
assert "background: var(--brand)" in tbody
|
||||
assert "color: var(--bg)" in tbody, "dark ink on brand (never white)"
|
||||
assert "min-height: 44px" in tbody
|
||||
assert re.search(r"#push-doc-btn:disabled \{[^}]*opacity[^}]*\}", css), (
|
||||
"the disabled (Pushing…) state must be styled"
|
||||
)
|
||||
|
||||
|
||||
def test_form_fields_css_mono_and_min_height() -> None:
|
||||
"""#draft-path and #draft-body are MONO (the path is machine data;
|
||||
the body is markdown) on the inset bg fill; #draft-body carries
|
||||
the pinned min-height: 20rem; the inputs keep the 44px floor."""
|
||||
css = _css()
|
||||
pair = re.search(
|
||||
r"#draft-title,\n#draft-path \{([\s\S]*?)\n\}", css
|
||||
)
|
||||
assert pair, "styles.css must style the two text inputs"
|
||||
assert "min-height: 44px" in pair.group(1)
|
||||
# The DEDICATED #draft-path rule (the pair above shares the name in
|
||||
# its selector list — search past the pair's closing brace).
|
||||
path = re.search(
|
||||
r"#draft-path \{([\s\S]*?)\n\}", css[pair.end():]
|
||||
)
|
||||
assert path and "var(--mono)" in path.group(1), "#draft-path must be mono"
|
||||
body = re.search(r"#draft-body \{([\s\S]*?)\n\}", css)
|
||||
assert body, "styles.css must style #draft-body"
|
||||
bbody = body.group(1)
|
||||
assert "var(--mono)" in bbody, "#draft-body must be mono"
|
||||
assert "min-height: 20rem" in bbody, "the pinned 20rem body floor"
|
||||
assert "resize: vertical" in bbody
|
||||
|
||||
|
||||
def test_status_and_error_css_families() -> None:
|
||||
""".doc-edit-status: the ok family (ok-ink on ok-bg 10.6:1) when
|
||||
a push outcome has landed, the dashed placeholder when empty;
|
||||
.doc-edit-error: the err family (err-ink on err-bg 9.3:1,
|
||||
err-line border) with long-word breaking (git paths). The global
|
||||
3px :focus-visible ring covers the new controls (AGENTS.md rule 5)."""
|
||||
css = _css()
|
||||
status = re.search(r"\.doc-edit-status \{([\s\S]*?)\n\}", css)
|
||||
assert status, "styles.css must style .doc-edit-status"
|
||||
sbody = status.group(1)
|
||||
assert "var(--ok-bg)" in sbody and "var(--ok-ink)" in sbody
|
||||
assert re.search(r"\.doc-edit-status:empty \{", css), (
|
||||
"the empty status must be the dashed placeholder"
|
||||
)
|
||||
error = re.search(r"\.doc-edit-error \{([\s\S]*?)\n\}", css)
|
||||
assert error, "styles.css must style .doc-edit-error"
|
||||
ebody = error.group(1)
|
||||
assert "var(--err-bg)" in ebody and "var(--err-ink)" in ebody
|
||||
assert "var(--err-line)" in ebody
|
||||
assert "overflow-wrap: anywhere" in ebody
|
||||
assert ":focus-visible" in css, "the global focus ring (AGENTS.md rule 5)"
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Unit tests: docs-push service (phase 59, task 03).
|
||||
|
||||
``push_document`` is exercised against a **real local git repo** — a
|
||||
bare origin in ``tmp_path`` plus the working clones the service creates
|
||||
itself — and every result assertion reads the bare repo's state
|
||||
directly (``git show <branch>:<path>``, ``git rev-list``), not the
|
||||
return value alone. The remote is a plain local path, so no network is
|
||||
ever involved.
|
||||
|
||||
The module skips (``pytest.skip``) when ``git --version`` fails — a
|
||||
machine without git must not see hard failures.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.docs_push import DocsPushError, push_document
|
||||
|
||||
BASE = "main"
|
||||
BRANCH = "bor-docs"
|
||||
REL = "docs/note.md"
|
||||
IDENTITY = ("-c", "commit.gpgsign=false", "-c", "user.name=Test", "-c", "user.email=t@example.com")
|
||||
|
||||
|
||||
def _git_available() -> bool:
|
||||
try:
|
||||
proc = subprocess.run(["git", "--version"], capture_output=True, check=False)
|
||||
return proc.returncode == 0
|
||||
except (FileNotFoundError, OSError):
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture(scope="module", autouse=True)
|
||||
def _require_git() -> None:
|
||||
"""Skip the whole module when the git CLI is missing."""
|
||||
if not _git_available():
|
||||
pytest.skip("git is not available on this machine")
|
||||
|
||||
|
||||
def _git(cwd: Path, *argv: str) -> str:
|
||||
"""Run git for the tests themselves (setup + assertions); loud on failure."""
|
||||
proc = subprocess.run(["git", *argv], cwd=cwd, capture_output=True, text=True, check=False)
|
||||
assert proc.returncode == 0, f"git {' '.join(argv)} failed: {proc.stderr}"
|
||||
return proc.stdout
|
||||
|
||||
|
||||
def _push(bare: Path, work: Path, content: str, message: str = "docs: note") -> tuple[str, str]:
|
||||
"""push_document against the fixture bare repo (plain local path)."""
|
||||
return push_document(
|
||||
repo=str(bare),
|
||||
base_branch=BASE,
|
||||
branch=BRANCH,
|
||||
work_dir=str(work),
|
||||
rel_path=REL,
|
||||
content=content,
|
||||
commit_message=message,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def bare_repo(tmp_path: Path) -> Path:
|
||||
"""A bare origin seeded with one commit on ``main`` (``README.md``)."""
|
||||
bare = tmp_path / "bare.git"
|
||||
_git(tmp_path, "init", "--bare", str(bare))
|
||||
seed = tmp_path / "seed"
|
||||
_git(tmp_path, "clone", str(bare), str(seed))
|
||||
(seed / "README.md").write_text("# docs\n", encoding="utf-8")
|
||||
_git(seed, "checkout", "-B", BASE)
|
||||
_git(seed, *IDENTITY, "add", "README.md")
|
||||
_git(seed, *IDENTITY, "commit", "-m", "seed README")
|
||||
_git(seed, "push", "origin", BASE)
|
||||
return bare
|
||||
|
||||
|
||||
def test_first_push_creates_branch_and_returns_sha(bare_repo: Path, tmp_path: Path) -> None:
|
||||
"""First push: clones the base, creates the branch, lands the file."""
|
||||
work = tmp_path / "work" # absent — push_document clones it
|
||||
branch, sha = _push(bare_repo, work, "# Note\n\nbody one\n")
|
||||
|
||||
assert branch == BRANCH
|
||||
assert (work / ".git").is_dir()
|
||||
assert (work / REL).read_text(encoding="utf-8") == "# Note\n\nbody one\n"
|
||||
# The file lands on the branch of the BARE repo, at the returned sha.
|
||||
assert _git(bare_repo, "show", f"{BRANCH}:{REL}") == "# Note\n\nbody one\n"
|
||||
assert _git(bare_repo, "rev-parse", BRANCH).strip() == sha
|
||||
assert len(sha) == 40
|
||||
# Exactly one commit beyond main.
|
||||
assert _git(bare_repo, "rev-list", "--count", f"main..{BRANCH}").strip() == "1"
|
||||
# Fixed per-invocation identity + message (no global git config reliance).
|
||||
ident = _git(bare_repo, "log", "-1", BRANCH, "--format=%an <%ae>").strip()
|
||||
assert ident == "Brain of Reese <bor@local>"
|
||||
assert _git(bare_repo, "log", "-1", BRANCH, "--format=%s").strip() == "docs: note"
|
||||
|
||||
|
||||
def test_second_push_fast_forwards_same_branch(bare_repo: Path, tmp_path: Path) -> None:
|
||||
"""Second push (edited content, same path): fast-forward, 2 commits."""
|
||||
work = tmp_path / "work"
|
||||
sha1 = _push(bare_repo, work, "v1\n")[1]
|
||||
sha2 = _push(bare_repo, work, "v2 edited\n")[1]
|
||||
|
||||
assert sha1 != sha2
|
||||
assert _git(bare_repo, "show", f"{BRANCH}:{REL}") == "v2 edited\n"
|
||||
assert _git(bare_repo, "rev-list", "--count", f"main..{BRANCH}").strip() == "2"
|
||||
# Fast-forward, no force: the first commit is still an ancestor.
|
||||
_git(bare_repo, "merge-base", "--is-ancestor", sha1, sha2)
|
||||
|
||||
|
||||
def test_fresh_checkout_reattaches_onto_remote_branch(bare_repo: Path, tmp_path: Path) -> None:
|
||||
"""An absent checkout re-attaches onto the existing remote branch
|
||||
(its history) so the push still fast-forwards."""
|
||||
work1 = tmp_path / "work1"
|
||||
sha1 = _push(bare_repo, work1, "v1\n")[1]
|
||||
work2 = tmp_path / "work2" # different dir — push_document clones anew
|
||||
branch, sha2 = _push(bare_repo, work2, "v2\n")
|
||||
|
||||
assert branch == BRANCH
|
||||
assert _git(bare_repo, "rev-list", "--count", f"main..{BRANCH}").strip() == "2"
|
||||
assert _git(bare_repo, "rev-parse", BRANCH).strip() == sha2
|
||||
# work2's commit sits on work1's commit (re-attach, not a fork).
|
||||
_git(bare_repo, "merge-base", "--is-ancestor", sha1, sha2)
|
||||
|
||||
|
||||
def test_concurrently_advanced_remote_fails_loudly(bare_repo: Path, tmp_path: Path) -> None:
|
||||
"""Remote advanced by a second clone → the first clone's push is a
|
||||
non-fast-forward: DocsPushError carrying git's stderr, remote kept."""
|
||||
work_a = tmp_path / "work_a"
|
||||
_push(bare_repo, work_a, "from A\n")
|
||||
|
||||
# A second clone advances the branch on the bare repo.
|
||||
work_b = tmp_path / "work_b"
|
||||
_git(tmp_path, "clone", "--depth", "1", "--branch", BRANCH, str(bare_repo), str(work_b))
|
||||
(work_b / "docs" / "other.md").write_text("from B\n", encoding="utf-8")
|
||||
_git(work_b, *IDENTITY, "add", "docs/other.md")
|
||||
_git(work_b, *IDENTITY, "commit", "-m", "docs: other")
|
||||
_git(work_b, "push", "origin", BRANCH)
|
||||
remote_tip_before = _git(bare_repo, "rev-parse", BRANCH).strip()
|
||||
|
||||
with pytest.raises(DocsPushError) as excinfo:
|
||||
_push(bare_repo, work_a, "from A again\n")
|
||||
|
||||
msg = str(excinfo.value)
|
||||
# git's stderr is surfaced (the non-fast-forward refusal).
|
||||
assert "non-fast-forward" in msg
|
||||
assert "rejected" in msg
|
||||
# The remote branch was NOT touched (no force-push, no merge).
|
||||
assert _git(bare_repo, "rev-parse", BRANCH).strip() == remote_tip_before
|
||||
assert _git(bare_repo, "show", f"{BRANCH}:{REL}") == "from A\n"
|
||||
|
||||
|
||||
def test_missing_repo_path_fails_loudly(tmp_path: Path) -> None:
|
||||
"""No such repo → DocsPushError naming the failed git step."""
|
||||
with pytest.raises(DocsPushError, match="git clone .* failed"):
|
||||
push_document(
|
||||
repo=str(tmp_path / "no-such-repo"),
|
||||
base_branch=BASE,
|
||||
branch=BRANCH,
|
||||
work_dir=str(tmp_path / "w"),
|
||||
rel_path=REL,
|
||||
content="x\n",
|
||||
commit_message="docs: x",
|
||||
)
|
||||
# No fake checkout is left behind.
|
||||
assert not (tmp_path / "w" / ".git").exists()
|
||||
|
||||
|
||||
def test_non_repo_dir_fails_loudly(tmp_path: Path) -> None:
|
||||
"""A plain directory (not a git repo) as the remote → DocsPushError."""
|
||||
plain = tmp_path / "plain"
|
||||
plain.mkdir()
|
||||
(plain / "file.txt").write_text("not a repo\n", encoding="utf-8")
|
||||
with pytest.raises(DocsPushError, match="failed"):
|
||||
push_document(
|
||||
repo=str(plain),
|
||||
base_branch=BASE,
|
||||
branch=BRANCH,
|
||||
work_dir=str(tmp_path / "w"),
|
||||
rel_path=REL,
|
||||
content="x\n",
|
||||
commit_message="docs: x",
|
||||
)
|
||||
|
||||
|
||||
def test_unsafe_rel_path_is_refused_before_any_git(bare_repo: Path, tmp_path: Path) -> None:
|
||||
"""The defensive parts re-assertion refuses traversal paths."""
|
||||
for bad in ("../evil.md", "/etc/passwd", "a/b/../c.md"):
|
||||
with pytest.raises(DocsPushError, match="unsafe rel_path"):
|
||||
push_document(
|
||||
repo=str(bare_repo),
|
||||
base_branch=BASE,
|
||||
branch=BRANCH,
|
||||
work_dir=str(tmp_path / "w"),
|
||||
rel_path=bad,
|
||||
content="x\n",
|
||||
commit_message="docs: x",
|
||||
)
|
||||
# No checkout was even attempted.
|
||||
assert not (tmp_path / "w").exists()
|
||||
@@ -30,6 +30,7 @@ HTML_PAGES = (
|
||||
"git-sources.html",
|
||||
"history.html", # phase 50: the admin saved-chats page
|
||||
"shared.html", # phase 51: the anonymous shared-conversation page
|
||||
"doc-edit.html", # phase 59: the admin doc edit screen (flow page)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -143,11 +143,11 @@ def test_missing_git_raises_named_error(
|
||||
clone_or_pull("https://example.com/homelab.git", tmp_path / "homelab")
|
||||
|
||||
|
||||
def test_run_captures_and_returns_stdout(
|
||||
def test_run_git_captures_and_returns_stdout(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""_run returns the captured stdout on success (git output is not lost)."""
|
||||
"""run_git returns the captured stdout on success (git output is not lost)."""
|
||||
calls = _fake_run(monkeypatch, stdout="From example.com\n + abc..def main")
|
||||
|
||||
assert git_sync._run(["git", "status"], cwd=tmp_path) == "From example.com\n + abc..def main"
|
||||
assert git_sync.run_git(["git", "status"], cwd=tmp_path) == "From example.com\n + abc..def main"
|
||||
assert len(calls) == 1
|
||||
|
||||
@@ -44,3 +44,36 @@ def test_documents_unique_source_path() -> None:
|
||||
and {col.name for col in c.columns} == {"source", "path"}
|
||||
]
|
||||
assert uq, "documents must be unique on (source, path) — the upsert key"
|
||||
|
||||
|
||||
def test_doc_drafts_token_is_unique_not_null() -> None:
|
||||
"""Phase 59: the draft's URL credential — an unguessable uuid4,
|
||||
UNIQUE + NOT NULL (no "un-drafted" state, unlike the NULLable
|
||||
``saved_chats.share_token``)."""
|
||||
drafts = Base.metadata.tables["doc_drafts"]
|
||||
token = drafts.c["token"]
|
||||
assert token.nullable is False, "doc_drafts.token must be NOT NULL"
|
||||
uq = [
|
||||
c
|
||||
for c in drafts.constraints
|
||||
if isinstance(c, UniqueConstraint)
|
||||
and {col.name for col in c.columns} == {"token"}
|
||||
]
|
||||
assert uq, "doc_drafts must be unique on (token) — the URL credential"
|
||||
|
||||
|
||||
def test_doc_drafts_column_contract() -> None:
|
||||
"""Phase 59: the editable triple (title/path/body) + status +
|
||||
timestamps are NOT NULL; ``branch`` / ``commit_sha`` are NULL
|
||||
until the push endpoint records them."""
|
||||
drafts = Base.metadata.tables["doc_drafts"]
|
||||
assert set(drafts.c.keys()) == {
|
||||
"id", "token", "title", "path", "body", "status",
|
||||
"branch", "commit_sha", "created_at", "updated_at",
|
||||
}
|
||||
for name in ("title", "path", "body", "status", "created_at", "updated_at"):
|
||||
assert drafts.c[name].nullable is False, f"{name} must be NOT NULL"
|
||||
for name in ("branch", "commit_sha"):
|
||||
assert drafts.c[name].nullable is True, f"{name} must be NULL until pushed"
|
||||
assert drafts.c["status"].default is not None, "status needs an ORM default (draft)"
|
||||
assert drafts.c["token"].default is not None, "token needs an ORM default (uuid4)"
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
"""Unit: the phase-59 "Save as doc" button (task 05).
|
||||
|
||||
No Python logic exists beyond the one-line ``app/api/config.py`` flag —
|
||||
the behavior lives in ``frontend/assets/app.js`` + ``brand.js`` +
|
||||
``styles.css``, and it is E2E-gated by the story suite (task 07). Like
|
||||
the other frontend-adjacent unit files (``test_frontend_brand.py``),
|
||||
this module pins the JS/CSS markers the story depends on, so a silent
|
||||
regression in the button layer is caught without a browser — plus the
|
||||
``app/api/config.py`` unit pin (the response dict's
|
||||
``docs_repo_configured`` bool tracks ``settings.docs_configured``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.config import Settings
|
||||
|
||||
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
|
||||
BRAND_JS = FRONTEND / "assets" / "brand.js"
|
||||
APP_JS = FRONTEND / "assets" / "app.js"
|
||||
STYLES_CSS = FRONTEND / "assets" / "styles.css"
|
||||
|
||||
|
||||
def _text(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _settings(**kwargs: Any) -> Settings:
|
||||
"""Build Settings without reading a .env file (deterministic tests).
|
||||
|
||||
Same house pattern as tests/integration/test_doc_drafts_api.py —
|
||||
``_env_file`` exists at runtime (pydantic-settings) but is not in the
|
||||
static signature, hence the ignore on the call.
|
||||
"""
|
||||
kwargs.setdefault("_env_file", None)
|
||||
return Settings(**kwargs) # pyright: ignore[reportCallIssue] (kwarg exists at runtime)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# app/api/config.py — the unit pin (the response dict gains the flag)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_app_config_dict_carries_the_docs_flag() -> None:
|
||||
"""The ``app_config`` response dict gains ``docs_repo_configured`` —
|
||||
a real bool that tracks ``settings.docs_configured``: false (inert)
|
||||
while BOR_DOCS_REPO is empty, true the moment it is non-empty."""
|
||||
from app.api.config import app_config
|
||||
|
||||
s = _settings()
|
||||
body = app_config(s)
|
||||
assert set(body) == {"app_name", "version", "docs_repo_configured"}
|
||||
assert body["docs_repo_configured"] is s.docs_configured
|
||||
assert body["docs_repo_configured"] is False
|
||||
|
||||
s2 = _settings(docs_repo="/srv/docs-repo")
|
||||
assert app_config(s2)["docs_repo_configured"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# brand.js — the flag + promise are surfaced the way app_name is
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_brand_js_surfaces_the_docs_flag_inert_by_default() -> None:
|
||||
"""window.BOR_DOCS_REPO_CONFIGURED is a classic-script global: false
|
||||
at parse time (inert — hidden for everyone until proven), BEFORE the
|
||||
/api/config fetch starts (the same ordering pin as window.BOR_BRAND)."""
|
||||
js = _text(BRAND_JS)
|
||||
assert "window.BOR_DOCS_REPO_CONFIGURED = false;" in js
|
||||
default_idx = js.find("window.BOR_DOCS_REPO_CONFIGURED = false;")
|
||||
# The real fetch statement (the file-header comment mentions the
|
||||
# fetch too — anchor on the parse-time const, not the comment).
|
||||
fetch_idx = js.find('BOR_CONFIG_PROMISE = fetch("/api/config"')
|
||||
assert 0 <= default_idx < fetch_idx, (
|
||||
"the inert flag default must be set at top level before the fetch"
|
||||
)
|
||||
|
||||
|
||||
def test_brand_js_exposes_the_config_promise_and_sets_the_flag() -> None:
|
||||
"""The SAME boot fetch's promise is exposed at parse time
|
||||
(window.BOR_CONFIG_PROMISE — app.js's boot awaits it), the flag lands
|
||||
the moment the answer arrives, and the promise NEVER rejects (the
|
||||
error arm warns + resolves null — the loadHealth house style)."""
|
||||
js = _text(BRAND_JS)
|
||||
assert "window.BOR_CONFIG_PROMISE = BOR_CONFIG_PROMISE;" in js
|
||||
assert "window.BOR_DOCS_REPO_CONFIGURED = cfg?.docs_repo_configured === true;" in js
|
||||
# The flag is a strict boolean: only the literal JSON true flips it.
|
||||
assert "=== true" in js
|
||||
assert "console.warn" in js
|
||||
assert "return null;" in js
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# app.js — boot wiring: the flag is final before any bubble renders
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_app_js_boot_awaits_config_before_capturing_the_flag() -> None:
|
||||
"""The boot IIFE awaits brand.js's parse-time promise (never
|
||||
rejecting — a defensive fallback covers a missing global) and then
|
||||
captures docsRepoConfigured — BEFORE any bubble renders
|
||||
(restoreConversation), so a restored conversation of a configured
|
||||
admin gets the button exactly once: no flash, no re-render, no
|
||||
second fetch."""
|
||||
js = _text(APP_JS)
|
||||
assert "let docsRepoConfigured = false;" in js
|
||||
await_idx = js.find("await (window.BOR_CONFIG_PROMISE ?? Promise.resolve());")
|
||||
capture_idx = js.find("docsRepoConfigured = window.BOR_DOCS_REPO_CONFIGURED === true;")
|
||||
restore_idx = js.find("restoreConversation();")
|
||||
assert await_idx >= 0 and await_idx < capture_idx, (
|
||||
"the flag capture must follow the config-promise await"
|
||||
)
|
||||
assert restore_idx > 0 and capture_idx < restore_idx, (
|
||||
"the flag must be final BEFORE the restored conversation renders"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# app.js — the button: gating, ARIA, one per bubble
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_app_js_button_gates_on_admin_and_configured() -> None:
|
||||
"""The single guard: admin (the whoami gate Tune uses) AND
|
||||
docs_repo_configured — otherwise the function injects NOTHING
|
||||
(anonymous, or unconfigured admin, or deflected scope — same as
|
||||
Tune). One button per bubble; the .msg-meta row is reused (or
|
||||
created plain) and a role=list row gets a listitem button (ARIA)."""
|
||||
js = _text(APP_JS)
|
||||
fn_idx = js.find("function appendSaveAsDocButton(wrap, markdown) {")
|
||||
assert fn_idx != -1, "appendSaveAsDocButton missing"
|
||||
fn_end = js.find("async function saveAsDoc", fn_idx)
|
||||
fn_body = js[fn_idx:fn_end]
|
||||
assert "if (!isAdmin || !docsRepoConfigured) return;" in fn_body
|
||||
assert 'meta.querySelector(".save-as-doc-btn")' in fn_body, (
|
||||
"the one-button-per-bubble guard is missing"
|
||||
)
|
||||
assert "meta.getAttribute(\"role\") === \"list\"" in fn_body
|
||||
assert "btn.setAttribute(\"role\", \"listitem\")" in fn_body
|
||||
|
||||
|
||||
def test_app_js_button_carries_the_class_and_label() -> None:
|
||||
"""The .save-as-doc-btn class (the CSS right-alignment hook) + the
|
||||
house label "Save as doc" (an accessible button name — the icon is
|
||||
aria-hidden decoration)."""
|
||||
js = _text(APP_JS)
|
||||
fn_idx = js.find("function appendSaveAsDocButton(wrap, markdown) {")
|
||||
fn_body = js[fn_idx : js.find("async function saveAsDoc", fn_idx)]
|
||||
assert 'btn.className = "save-as-doc-btn"' in fn_body
|
||||
assert 'btn.type = "button"' in fn_body
|
||||
assert "<span>Save as doc</span>" in fn_body
|
||||
# The file glyph is aria-hidden decoration (the label carries the
|
||||
# accessible name) — the icon constant, which the function consumes.
|
||||
icon_idx = js.find("const SAVE_AS_DOC_ICON")
|
||||
icon_body = js[icon_idx : js.find("const DOC_TITLE_MAX", icon_idx)]
|
||||
assert 'aria-hidden="true"' in icon_body, "the icon must be aria-hidden"
|
||||
assert 'SAVE_AS_DOC_ICON + "<span>Save as doc</span>"' in fn_body
|
||||
|
||||
|
||||
def test_app_js_call_sites_pass_the_raw_markdown() -> None:
|
||||
"""Three call sites, each passing the RAW persisted markdown (never
|
||||
the rendered HTML): the live `done` branch (exactly the string
|
||||
rememberBrainTurn stores, so a reload offers the identical draft),
|
||||
the empty-answer fallback bubble (parity with the done path), and
|
||||
the restore path (m.text). A stopped partial is a note, not an
|
||||
answer — the restore gates on !m.stopped; the live stop path and
|
||||
the pagehide partial never call the helper at all."""
|
||||
js = _text(APP_JS)
|
||||
assert 'appendSaveAsDocButton(wrap, finalText || acc || "…");' in js, (
|
||||
"the live done branch must pass the raw persisted text"
|
||||
)
|
||||
assert "appendSaveAsDocButton(fwrap, fallback);" in js, (
|
||||
"the empty-answer fallback bubble must get the button too"
|
||||
)
|
||||
assert "if (!m.stopped) appendSaveAsDocButton(wrap, m.text);" in js, (
|
||||
"the restore path must pass m.text and skip stopped records"
|
||||
)
|
||||
# The live call sits next to the Tune button (same meta row scope).
|
||||
tune_idx = js.find("appendTuneButton(wrap); // every completed brain bubble is tunable")
|
||||
save_idx = js.find('appendSaveAsDocButton(wrap, finalText || acc || "…");')
|
||||
assert tune_idx > 0 and tune_idx < save_idx
|
||||
# The stop finalize keeps its Tune button but gains NO save button
|
||||
# (a stopped partial is a note, not an answer) — none between the
|
||||
# stop call site and the pagehide handler (which persists, it does
|
||||
# not render).
|
||||
stop_idx = js.find("appendTuneButton(wrap); // admin-only; parity with the restore path")
|
||||
pagehide_idx = js.find("pagehide", stop_idx)
|
||||
assert stop_idx > 0 and stop_idx < pagehide_idx
|
||||
assert "appendSaveAsDocButton" not in js[stop_idx:pagehide_idx], (
|
||||
"the stopped partial (note, not answer) must not get the button"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# app.js — the click: payload, slug rule, navigation, failure copy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_app_js_default_title_is_the_last_user_question() -> None:
|
||||
"""The default title: the LAST user question's text,
|
||||
whitespace-collapsed, ≤120 chars (the phase-50 auto-title
|
||||
convention — the chat auto-title targets the FIRST question, the
|
||||
docs default the LAST). Defensive "Note" with no user record."""
|
||||
js = _text(APP_JS)
|
||||
assert "const DOC_TITLE_MAX = 120;" in js
|
||||
fn_idx = js.find("function defaultDocTitle() {")
|
||||
assert fn_idx != -1, "defaultDocTitle missing"
|
||||
fn_body = js[fn_idx : js.find("function docSlug", fn_idx)]
|
||||
assert "conversation.length - 1" in fn_body, (
|
||||
"the LAST user record wins (iterate backwards)"
|
||||
)
|
||||
assert 'conversation[i].who === "user"' in fn_body
|
||||
assert 'question.replace(/\\s+/g, " ").trim().slice(0, DOC_TITLE_MAX)' in fn_body
|
||||
assert '|| "Note"' in fn_body
|
||||
|
||||
|
||||
def test_app_js_slug_rule() -> None:
|
||||
"""The default in-repo path slug: lowercase → runs of
|
||||
non-alphanumerics → "-" → trimmed → ≤60 chars → empty → "note"
|
||||
(the phase-59 locked assumption; a 60-cut mid dash-run is trimmed
|
||||
again so the path never dangles)."""
|
||||
js = _text(APP_JS)
|
||||
fn_idx = js.find("function docSlug(title) {")
|
||||
assert fn_idx != -1, "docSlug missing"
|
||||
fn_body = js[fn_idx : js.find("function appendSaveAsDocButton", fn_idx)]
|
||||
assert ".toLowerCase()" in fn_body
|
||||
assert '.replace(/[^a-z0-9]+/g, "-")' in fn_body
|
||||
assert '.replace(/^-+|-+$/g, "")' in fn_body
|
||||
assert ".slice(0, 60)" in fn_body
|
||||
assert '|| "note"' in fn_body
|
||||
# The default in-repo path is docs/<slug>.md.
|
||||
assert "docs/${docSlug(title)}.md" in js
|
||||
|
||||
|
||||
def test_app_js_post_payload_and_navigation() -> None:
|
||||
"""Click → POST /api/doc-drafts {title, path, body: markdown} (the
|
||||
raw markdown is the body — never HTML) → 201 →
|
||||
location.assign("/doc-edit.html?draft=" + token). A double-click
|
||||
guard disables the button until the outcome (released in the
|
||||
finally — never stale); failure shows the neutral one-line banner
|
||||
(phase-55 convention) and never navigates."""
|
||||
js = _text(APP_JS)
|
||||
fn_idx = js.find("async function saveAsDoc(btn, markdown) {")
|
||||
assert fn_idx != -1, "saveAsDoc missing"
|
||||
fn_body = js[fn_idx : fn_idx + 3000]
|
||||
assert 'fetch("/api/doc-drafts"' in fn_body
|
||||
assert 'JSON.stringify({ title, path, body: markdown })' in fn_body
|
||||
assert 'location.assign("/doc-edit.html?draft=" + draft.token)' in fn_body
|
||||
assert "btn.disabled = true" in fn_body
|
||||
assert "btn.disabled = false" in fn_body
|
||||
assert "showErrorBanner(" in fn_body
|
||||
# The neutral one-line failure copy (phase-55 convention).
|
||||
assert "Couldn't save the answer as a doc" in fn_body
|
||||
|
||||
|
||||
def test_app_js_retry_landing_keeps_save_rightmost() -> None:
|
||||
"""markLastRetryable re-appends the save button AFTER the Retry
|
||||
button lands on the same (last) bubble — the auto-margined buttons
|
||||
split the row's free space between them, so DOM order decides the
|
||||
right edge: "Save as doc" stays the bottom-right action even on the
|
||||
last bubble (which also carries Retry)."""
|
||||
js = _text(APP_JS)
|
||||
fn_idx = js.find("function markLastRetryable() {")
|
||||
assert fn_idx != -1
|
||||
fn_end = js.find("/* Phase 59 (owner-locked 2026-08-31, TODO.md L3): the bottom-right", fn_idx)
|
||||
fn_body = js[fn_idx:fn_end]
|
||||
assert "appendRetryButton(lastBrainWrap);" in fn_body
|
||||
assert 'lastBrainWrap.querySelector(".save-as-doc-btn")' in fn_body
|
||||
# "saveDocBtn" — NOT "saveBtn": phase 55 pins the Save pill's
|
||||
# identifier gone from app.js (substring), so the local stays distinct.
|
||||
assert "saveDocBtn.parentElement.appendChild(saveDocBtn)" in fn_body
|
||||
assert "saveBtn" not in _text(APP_JS), (
|
||||
"the phase-55 pin: no saveBtn identifier in app.js"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# styles.css — the .tune-btn visual family + the right alignment
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_styles_css_save_as_doc_btn_is_right_aligned() -> None:
|
||||
""".save-as-doc-btn exists, carries the bottom-right declaration
|
||||
(margin-inline-start: auto) and the .tune-btn visual family (pill,
|
||||
>=44px target, line border, ink-soft palette); :focus-visible is
|
||||
the global rule, the hover rule is per-class."""
|
||||
css = _text(STYLES_CSS)
|
||||
m = re.search(r"\.save-as-doc-btn \{[^}]*\}", css)
|
||||
assert m, "the .save-as-doc-btn rule is missing"
|
||||
block = m.group(0)
|
||||
assert "margin-inline-start: auto;" in block, (
|
||||
"the bottom-right requirement lives on the button's class"
|
||||
)
|
||||
assert "min-height: 44px;" in block # WCAG touch target (the family)
|
||||
assert "border-radius: 999px;" in block
|
||||
assert "border: 1px solid var(--line);" in block
|
||||
assert "var(--ink-soft)" in block
|
||||
assert ".save-as-doc-btn:hover" in css
|
||||
assert ".save-as-doc-btn svg" in css # the 14px house glyph sizing
|
||||
assert ":focus-visible" in css # the global focus ring (AGENTS §5)
|
||||
@@ -16,10 +16,11 @@ without a browser:
|
||||
``.shared-shell``, ``.doc-md``, ``.doc-summary:has(+ .doc-md)`` —
|
||||
each capped with ``max-width: var(--chat-column)`` and NOTHING else
|
||||
in the file uses the token (exactly four rules);
|
||||
* the negative pin — ``.tuning-shell`` (a form, not a reading
|
||||
surface) keeps its hard-coded ``max-width: 46rem`` at every width,
|
||||
and it is the only literal ``max-width: 46rem`` rule left in the
|
||||
file;
|
||||
* the negative pin — the form columns (``.tuning-shell``; and from
|
||||
phase 59, task 06, ``.doc-edit-shell`` — forms, not reading
|
||||
surfaces) are the only literal ``max-width: 46rem`` rules left in
|
||||
the file, kept hard-coded so the wide-desktop doubling never
|
||||
stretches a form;
|
||||
* the "46rem column contract" block comments were updated to name the
|
||||
base value + the wide override (the stale "≤46rem" contract claims
|
||||
are gone from the reading-column comments).
|
||||
@@ -166,15 +167,21 @@ def test_tuning_shell_stays_hardcoded_46rem() -> None:
|
||||
|
||||
|
||||
def test_no_other_hardcoded_46rem_rule_remains() -> None:
|
||||
"""After the switch, the .tuning-shell rule is the ONLY rule with
|
||||
a literal max-width: 46rem — every reading column rides the
|
||||
token (the --chat-column base declaration is the other
|
||||
non-rule occurrence of 46rem)."""
|
||||
"""After the switch, the form columns are the ONLY rules with a
|
||||
literal max-width: 46rem: .tuning-shell (phase 27) and
|
||||
.doc-edit-shell (phase 59, task 06 — the doc edit screen is a
|
||||
FORM column, not a reading column, so it must not ride
|
||||
--chat-column and phase 58's wide-desktop doubling must never
|
||||
stretch the form). Every reading column rides the token (the
|
||||
--chat-column base declaration is the other non-rule occurrence
|
||||
of 46rem)."""
|
||||
css = _css()
|
||||
assert css.count("max-width: 46rem") == 1, (
|
||||
"only .tuning-shell may keep a literal max-width: 46rem"
|
||||
assert css.count("max-width: 46rem") == 2, (
|
||||
"only the form columns (.tuning-shell, .doc-edit-shell) may "
|
||||
"keep a literal max-width: 46rem"
|
||||
)
|
||||
assert "max-width: 46rem" in _rule_block(css, ".tuning-shell")
|
||||
assert "max-width: 46rem" in _rule_block(css, ".doc-edit-shell")
|
||||
|
||||
|
||||
def test_comments_cite_the_wide_override_with_provenance() -> None:
|
||||
|
||||
Reference in New Issue
Block a user