feat(docs): save chat answers as docs — edit screen, commit + push to the .env docs branch
This commit is contained in:
+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
|
||||
|
||||
Reference in New Issue
Block a user