From 3bc8fe92af4b6e03e262c4094a915c3c32ae530a Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Sun, 24 May 2026 04:35:14 -0700 Subject: [PATCH] =?UTF-8?q?Slice=202:=20the=20=C2=A78=20active-RFC=20view?= =?UTF-8?q?=20in=20full?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the §19.1 brief: the three-column shape (§8.1) opens on main in discuss mode (§8.2), supports the §8.3 discuss-vs-contribute flip on non-main branches, hosts §8.4's per-branch chat with AI participation (§18's protocol → §8.14 changes rows), the §8.8 change-card panel with §8.9 accept/decline/edit-before-accept, the §8.10 tracked-change markup + DiffView toggle, the §8.11 manual-edit flushes with the stale-change mechanic, the §8.12 range and paragraph sub-threads, the §8.13 flag affordance, and the §8.14 discuss-mode buffer. Backend: bot.py grew per-RFC-repo write ops (cut_branch_from_main, commit_accepted_change with the structured original/proposed/reason body and Change-Id + Source-Message-Id + On-behalf-of trailers, commit_manual_flush, ensure_rfc_repo_seed). cache.py grew refresh_rfc_repo and the webhook dispatches on repository.full_name. providers.py and chat.py port the §18 carryovers — multi-provider LLM abstraction and SSE-streaming chat against the §5 threads / thread_messages / changes schema. api_branches.py mounts the §17 branches//* and threads//* routes with the §6 / §11 permission checks inline. Frontend: RFCView.jsx rebuilt as the §8 surface; Editor.jsx, ChatPanel.jsx, ChangePanel.jsx, PromptBar.jsx, SelectionTooltip.jsx, DiffView.jsx, ModelPicker.jsx, modelStyles.js lifted from the prototype and adapted to the canonical schema. Covered by `backend/tests/test_rfc_view_vertical.py` — eleven new integration tests against an extended FakeGitea (PUT contents, POST orgs/{org}/repos, seed_rfc_repo): main-view read, promote-to-branch, accept (with and without edit-before-accept), decline, manual flush + system message, flag creation, visibility flip, anonymous read-but-no-contribute, stale-change refusal, and the chat-streaming path with a fake provider injected. The 5 Slice 1 tests continue to pass alongside. Co-Authored-By: Claude Opus 4.7 (1M context) --- SPEC.md | 183 ++- backend/app/api.py | 19 +- backend/app/api_branches.py | 1130 ++++++++++++++++++ backend/app/bot.py | 213 ++++ backend/app/cache.py | 144 +++ backend/app/chat.py | 321 +++++ backend/app/main.py | 15 +- backend/app/providers.py | 195 +++ backend/app/webhooks.py | 35 +- backend/tests/test_propose_vertical.py | 60 +- backend/tests/test_rfc_view_vertical.py | 564 +++++++++ docs/DEV.md | 166 ++- frontend/src/App.css | 569 +++++++++ frontend/src/App.jsx | 2 +- frontend/src/api.js | 186 +++ frontend/src/components/ChangePanel.jsx | 236 ++++ frontend/src/components/ChatPanel.jsx | 196 +++ frontend/src/components/DiffView.jsx | 114 ++ frontend/src/components/Editor.jsx | 191 +++ frontend/src/components/ModelPicker.jsx | 29 + frontend/src/components/PromptBar.jsx | 65 + frontend/src/components/RFCView.jsx | 807 ++++++++++++- frontend/src/components/SelectionTooltip.jsx | 121 ++ frontend/src/modelStyles.js | 23 + 24 files changed, 5433 insertions(+), 151 deletions(-) create mode 100644 backend/app/api_branches.py create mode 100644 backend/app/chat.py create mode 100644 backend/app/providers.py create mode 100644 backend/tests/test_rfc_view_vertical.py create mode 100644 frontend/src/components/ChangePanel.jsx create mode 100644 frontend/src/components/ChatPanel.jsx create mode 100644 frontend/src/components/DiffView.jsx create mode 100644 frontend/src/components/Editor.jsx create mode 100644 frontend/src/components/ModelPicker.jsx create mode 100644 frontend/src/components/PromptBar.jsx create mode 100644 frontend/src/components/SelectionTooltip.jsx create mode 100644 frontend/src/modelStyles.js diff --git a/SPEC.md b/SPEC.md index 022d232..ed8f4c6 100644 --- a/SPEC.md +++ b/SPEC.md @@ -2405,63 +2405,109 @@ surface. With Topic 13 folded in, the structural surface is complete. What follows is no longer "topics that block specifying v1" but "topics to address during or shortly after the v1 build." -### 19.1 Next slice: the active-RFC view in full +### 19.1 Next slice: the PR flow -Slice 1 of the build has landed. The repository scaffolding -(`backend/`, `frontend/`, `scripts/`, `docs/`) is in place; the §5 -canonical app tables exist as numbered SQLite migrations with the -§4 cache mirror beside them; the §1 bot wrapper is the single -chokepoint every Git write flows through, with the §6.5 -`On-behalf-of:` trailer applied uniformly and an `actions` row -recorded; Gitea OAuth provisions a `users` row on first sign-in -with role resolved from `OWNER_GITEA_LOGIN`; the §4.1 webhook -receiver and the periodic reconciler both write to the cache and -neither user actions nor the API do; the §7 left pane (catalog -with search, sort, state-filter chips, pending-ideas disclosure, -"+ Propose New RFC" button) renders against `GET /api/rfcs` and -`GET /api/proposals`; and the end-to-end propose-to-super-draft -vertical works: propose modal opens the idea PR, owner merges from -the pending-idea view, webhook (or reconciler sweep) updates the -cache, the catalog crossfades the super-draft in, and the -super-draft view renders the body. The vertical is covered by -integration tests against an in-process Gitea simulator. +Slice 2 of the build has landed. The §8 active-RFC view is wired +end-to-end against the local Gitea: the three-column shape (§8.1) +inherits the §7 catalog on the left, hosts a Tiptap editor in the +center with a breadcrumb dropdown listing main + open branches + +open PRs, and surfaces per-branch chat plus the change-card panel +on the right. Opening an active RFC lands on `main` in discuss mode +per §8.2; `Start Contributing` on main calls the §17 +promote-to-branch endpoint to cut a new branch via the bot and +re-anchors any pending `main`-scoped `changes` rows to it (§8.14). +On a non-main branch the §8.3 discuss-vs-contribute toggle flips +the editor between read-only and edit-enabled. The §18 carryovers +landed in `backend/app/providers.py` and `backend/app/chat.py` and +on the frontend as `Editor.jsx`, `ChatPanel.jsx`, `ChangePanel.jsx`, +`PromptBar.jsx`, `SelectionTooltip.jsx`, `DiffView.jsx`, and +`ModelPicker.jsx`. AI chat parses `` blocks per §18 into +`changes` rows with `state='pending'` per §8.14; accept runs the +bot's per-accepted-change commit (§8.6) with the structured body +and `Change-Id`, `Source-Message-Id`, and `On-behalf-of:` trailers; +decline persists the row as evidence per §8.9; edit-before-accept +preserves the AI's original text under an `AI proposed:` section +of the commit body per §8.9. Manual edits flush as one commit per +window with a system-author chat message landing per §10.6. The +§8.10 tracked-change markup is session-local in the editor; DiffView +is the read-only render of the same accepted changes. The §8.11 +stale-change machinery sets `changes.stale_since` when a manual +edit changes the document such that a pending AI proposal's +`original` no longer locates; the re-ask and force-apply paths are +wired. The §8.12 range threads (via the selection tooltip) and the +§8.13 flag threads (via the selection tooltip's flag tab) materialize +as `threads` rows scoped to the branch; the chat feed renders them +inline with the whole-doc default thread. The §11.1 visibility and +§6.4 contribute grants are wired with the branch-creator / +arbiter / admin set per §11.1, §11.2, §6.3. The §4 cache grew a +`refresh_rfc_repo` path that the webhook dispatches per +`repository.full_name` and the reconciler sweeps for every active +entry. The vertical is covered by `backend/tests/test_rfc_view_vertical.py` +— eleven integration tests against an extended FakeGitea that +supports per-RFC repos. -Several §9 affordances that depend on infrastructure that has not -yet been built were deferred from Slice 1 to Slice 2 — they are -not new candidate topics, only delivery sequencing: +Several §8 / §10 affordances were deferred from Slice 2 to later +slices — they're not new candidate topics, only delivery sequencing: -- The §9.1 propose modal's AI-suggested tags and the §9.2 - AI-drafted PR description — the AI surface lands with chat. -- The §9.3 two-step composer-then-preview decline dialog — - shipped as a single-step required-comment input in Slice 1, with - the preview-and-confirm ceremony pulled into the existing §19.2 - "pending-idea view's interaction design (remainder)" topic - alongside the merge-confirmation ceremony. -- The §9.3 pre-merge chat thread on a pending-idea view and its - migration to the super-draft on merge — depends on the per-RFC - / per-branch chat infrastructure Slice 2 builds. +- **Super-draft body editing on meta-repo edit branches (§9.5).** + The `branches/` machinery is structurally general enough + that meta-repo edit branches fall out of it once Slice 4 wires + the super-draft view's "Start Contributing" to cut against the + meta repo. The Slice 2 RFCView renders a placeholder for + super-draft entries pointing at Slice 4. +- **PR-anchored review threads (§10.4).** `thread_kind='review'` is + in the §5 schema and the threads endpoints honor it generically, + but the PR-page surface that anchors review threads to diff + hunks lands with Slice 3. +- **DiffView's full reconstruction from `changes` history.** Slice 2 + 's DiffView renders the editor's current HTML, which carries the + session-local tracked-change markup from accepts done in the + current session. Rebuilding the markup for accepted changes + earlier in branch history is the §19.2 "persistent + accepted-change markup" topic; the §8.10 framing already commits + the markup to session-local scope and points returning + contributors at DiffView, which is the durable artifact. +- **The §10.6 PR-side seen-cursor reconciliation.** Manual-edit + flushes drop a system-author message per §10.6 in Slice 2, but + the per-PR seen-cursor that uses the marker ships with Slice 3. -**Slice 2 is the active-RFC view per §8 in full.** The view -inherits the three-column shape (§8.1), opens on `main` in -discuss mode by default (§8.2), supports the §8.3 -discuss-vs-contribute mode flip on non-main branches, hosts §8.4's -per-branch chat with AI participation (the §18 `` -protocol parsing into `changes` rows per §8.6), the §8.8 -change-card panel with §8.9's accept / decline / -edit-before-accept resolution, the §8.10 tracked-change markup -and DiffView toggle, the §8.11 manual-edit flushes, the §8.12 -range and paragraph sub-threads, the §8.13 flag affordance, and -the §8.14 discuss-mode buffer. The carryover assets — the Tiptap -configuration, the SelectionTooltip, the `` parser, the -prompt-bar selection-quote machinery, the multi-provider LLM -abstraction, the SSE streaming — are present in working form in -the prototype at -`/Users/benstull/projects/wiggleverse/rfc-app-prototype/` and -should be lifted directly per §18. +**Slice 3 is the PR flow per §10 in full.** Open a PR via the +§10.1 affordance on a branch (with the §11.3 universal-public +confirmation when the branch is private); the §10.2 AI-drafted +creation modal pulls title and description from the diff plus the +branch chat. The §10.3 PR review page inherits the §8.1 +three-column shape — catalog left, diff (unified or split) in the +center, the compressed conversation plus the inline review-comment +surface (§10.4) on the right. The §10.3 per-user seen-cursor +mechanism accents new hunks and new conversation messages on the +next visit. The §10.4 review comments materialize as +`thread_kind='review'`, `anchor_kind='range'` threads anchored to +the post-PR document state, surfaced inline with the AI chat +visually distinguished. §10.5 merge writes a no-fast-forward merge +commit preserving the per-accepted-change commit nodes from §8.6 +as individual reachable commits in main's history. §10.6 update- +after-open re-renders the diff as new commits arrive (which they +already do — Slice 2's manual-flush and accept-change paths both +push immediately). §10.7 post-merge renders the PR read-only with +a `Merged` banner and starts §12's 90-day deletion timer. §10.8 +withdraw closes the PR with the same read-only treatment. §10.9 +conflict-replay cuts a fresh resolution branch off main's tip, +replays the source branch's diff (running the AI participant +against unambiguous conflicts and surfacing the rest to the +contributor), and opens a new PR with the original auto-closing on +merge. + +The carryover assets Slice 3 inherits: none new from the prototype +beyond what Slice 2 already lifted. The prototype's `PRModal.jsx` +was a one-shot submission flow; §10's PR creation modal is its +descendant but the spec broadened the surface considerably. The +seen-cursor advances are pure schema work — `pr_seen` and +`branch_chat_seen` are in the §5 schema; Slice 3 wires the +advance-on-view reconciler from §15.7. The next build session should read `SPEC.md`, `README.md`, and -`docs/DEV.md` and pick up Slice 2 cleanly without re-briefing. -The working agreement in §19.3 carries forward: implement the +`docs/DEV.md` and pick up Slice 3 cleanly without re-briefing. The +working agreement in §19.3 continues to apply: implement the slice, correct the spec only where running code reveals it was wrong at a structural level, accumulate new candidate topics in §19.2, do not extend the spec beyond what the slice requires. @@ -2570,6 +2616,41 @@ binding. topic once the cost of "the cache thinks the bot proposed everything pre-app" becomes concrete. Touches §4.1 (the reconciler's job description) and §15.9 (the attribution rule). +- **Branch-name path routing.** Slice 2's `branches/` + endpoints use FastAPI's default `{branch}` path-segment matcher, + which refuses slashes. The Slice 2 auto-generated branch name + steered around this with `-draft-`, but a user who + renames to a slashed name will 404 on read. The fix is to convert + every `branches/` route to `{branch:path}` with the + understanding that ordering matters (more-specific routes like + `branches/main/promote-to-branch` must register first). Surfaced + by Slice 2's testing; defer-able until a user actually wants a + slashed branch name. +- **Markdown round-trip fidelity in the editor.** Slice 2's manual- + flush converts the Tiptap document to text via `editor.getText()`, + which discards markdown structure on round-trip (lists become + flat lines, headings lose their `#`, links collapse to their text + content). A faithful HTML-to-markdown serializer — or switching + the on-disk format to a structured one the editor owns natively + — earns its own session once usage shows where the loss bites. + Touches §8.6 (commit unit) and §8.11 (the manual-edit card's + diff fidelity). +- **The chat feed's per-thread filter affordances.** §8.12 commits a + top-of-chat disclosure that lists open threads with anchor previews + and per-thread filter affordances. Slice 2 wired the disclosure + counts; the filter that collapses the feed down to a single + thread, and the per-thread "Reply" affordance that posts back into + a specific thread from the unified feed, are the natural follow-on. + Small scope, defer-able until the feed grows busy enough to + warrant. +- **Cross-branch source-message labelling.** §8.14's data-model rule + permits a `changes` row whose `source_message_id` points at a + message in main's chat — the row's `branch_name` was mutated from + `main` to the new branch on promote-to-branch, but the message + reference stays. Slice 2's frontend doesn't yet label these as + "from a conversation on main" in the change panel; a small visual + treatment is the natural follow-on. Surfaced by §8.14's data path + going through Slice 2 for the first time. - **Body full-text search.** When the time comes. Topic 13 (notifications) is settled and folded into §5 (the diff --git a/backend/app/api.py b/backend/app/api.py index 3ad419d..632aca6 100644 --- a/backend/app/api.py +++ b/backend/app/api.py @@ -17,10 +17,11 @@ from typing import Any from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel, Field -from . import auth, db, entry as entry_mod, cache +from . import api_branches, auth, db, entry as entry_mod, cache from .bot import Bot from .config import Config from .gitea import Gitea, GiteaError +from .providers import BaseProvider class ProposeBody(BaseModel): @@ -34,8 +35,22 @@ class DeclineBody(BaseModel): comment: str = Field(min_length=1, max_length=4000) -def make_router(config: Config, gitea: Gitea, bot: Bot) -> APIRouter: +def make_router( + config: Config, + gitea: Gitea, + bot: Bot, + providers: dict[str, BaseProvider] | None = None, +) -> APIRouter: + # Use `is None` rather than `providers or {}` — an empty dict is + # falsy, and the test harness mutates the dict the closure holds to + # inject a fake provider; substituting a fresh `{}` here would + # silently drop those mutations. + if providers is None: + providers = {} router = APIRouter() + # Slice 2: the §8 active-RFC view's endpoints live in api_branches. + # Mounting them on the same router keeps the §17 layout flat. + router.include_router(api_branches.make_router(config, gitea, bot, providers)) # --------------------------------------------------------------- # Auth surface — extends the prototype's pattern but reads role diff --git a/backend/app/api_branches.py b/backend/app/api_branches.py new file mode 100644 index 0000000..ae1933f --- /dev/null +++ b/backend/app/api_branches.py @@ -0,0 +1,1130 @@ +"""Slice 2 API surface — the §8 active-RFC view's endpoints. + +Owns every `branches//...` and `threads//...` route +from §17. Read paths fetch branch bodies live from Gitea (§4 #3 +exempts branch bodies from the cache); write paths funnel through +`bot.py` so the §1 chokepoint and the §6.5 trailer hold. + +Visibility and contribute decisions are enforced inline here against +the §6 four-role model plus the §11 per-branch visibility/contribute +rules; the app's permission model is canonical, and Gitea sees only +the bot. +""" +from __future__ import annotations + +import asyncio +import json +import logging +import re +from typing import Any + +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import StreamingResponse +from pydantic import BaseModel, Field + +from . import auth, cache, chat as chat_layer, db +from .bot import Bot +from .config import Config +from .gitea import Gitea, GiteaError +from .providers import BaseProvider + +log = logging.getLogger(__name__) + +RFC_FILE_PATH = "RFC.md" + + +# --------------------------------------------------------------------------- +# Request bodies +# --------------------------------------------------------------------------- + + +class PromoteToBranchBody(BaseModel): + branch_name: str | None = Field(default=None, max_length=120) + + +class AcceptChangeBody(BaseModel): + proposed: str = Field(min_length=0) + was_edited_before_accept: bool = False + force_apply_stale: bool = False + + +class ManualFlushBody(BaseModel): + new_content: str + paragraph_count: int = Field(ge=1) + + +class VisibilityBody(BaseModel): + read_public: bool | None = None + contribute_mode: str | None = Field(default=None, pattern="^(just-me|specific|any-contributor)$") + + +class GrantBody(BaseModel): + grantee_gitea_login: str = Field(min_length=1, max_length=80) + + +class ThreadCreateBody(BaseModel): + thread_kind: str = Field(pattern="^(chat|flag)$") + anchor_kind: str = Field(pattern="^(whole-doc|range|paragraph)$") + anchor_payload: dict | None = None + label: str | None = Field(default=None, max_length=400) + message: str | None = Field(default=None, max_length=20_000) + + +class ThreadMessageBody(BaseModel): + text: str = Field(min_length=1, max_length=20_000) + quote: str | None = Field(default=None, max_length=2000) + + +class ChatTurnBody(BaseModel): + text: str = Field(min_length=1, max_length=20_000) + quote: str | None = Field(default=None, max_length=2000) + model: str | None = None + + +# --------------------------------------------------------------------------- +# Router +# --------------------------------------------------------------------------- + + +def make_router( + config: Config, + gitea: Gitea, + bot: Bot, + providers: dict[str, BaseProvider], +) -> APIRouter: + router = APIRouter() + + default_model = next(iter(providers)) if providers else "" + + # ------------------------------------------------------------------- + # §17: model picker (the prototype carryover, scoped here since + # Slice 2 is where chat lights up). + # ------------------------------------------------------------------- + + @router.get("/api/models") + async def list_models() -> dict[str, Any]: + return { + "models": [ + {"id": key, "name": p.display_name} + for key, p in providers.items() + ], + "default": default_model, + } + + # ------------------------------------------------------------------- + # §17: GET /api/rfcs//main + # Body + branches + open PRs for the breadcrumb dropdown. + # ------------------------------------------------------------------- + + @router.get("/api/rfcs/{slug}/main") + async def get_rfc_main(slug: str, request: Request) -> dict[str, Any]: + viewer = auth.current_user(request) + rfc = _require_active_rfc(slug) + + # Branches the viewer can read per §11.1. + branch_rows = db.conn().execute( + """ + SELECT branch_name, head_sha, state, last_commit_at, pinned + FROM cached_branches + WHERE rfc_slug = ? AND state != 'deleted' + ORDER BY last_commit_at DESC NULLS LAST + """, + (slug,), + ).fetchall() + branches = [ + _branch_summary(slug, br, viewer) + for br in branch_rows + if _can_read_branch(slug, br["branch_name"], viewer) + ] + + pr_rows = db.conn().execute( + """ + SELECT pr_number, title, state, head_branch, opened_by, opened_at + FROM cached_prs + WHERE rfc_slug = ? AND pr_kind = 'rfc_branch' AND state = 'open' + ORDER BY opened_at DESC + """, + (slug,), + ).fetchall() + prs = [ + { + "pr_number": r["pr_number"], + "title": r["title"], + "state": r["state"], + "head_branch": r["head_branch"], + "opened_by": r["opened_by"], + "opened_at": r["opened_at"], + } + for r in pr_rows + ] + + return { + "slug": slug, + "title": rfc["title"], + "id": rfc["rfc_id"], + "repo": rfc["repo"], + "body": rfc["body"] or "", + "body_sha": rfc["body_sha"], + "branches": branches, + "open_prs": prs, + } + + # ------------------------------------------------------------------- + # §17: GET /api/rfcs//branches/ + # Per §4: branch bodies are NOT cached — fetch live from Gitea. + # ------------------------------------------------------------------- + + @router.get("/api/rfcs/{slug}/branches/{branch}") + async def get_branch_view(slug: str, branch: str, request: Request) -> dict[str, Any]: + viewer = auth.current_user(request) + rfc = _require_active_rfc(slug) + if not _can_read_branch(slug, branch, viewer): + raise HTTPException(403, "Branch is private") + + owner, repo = _owner_repo(rfc) + # Ensure branch exists in cache so freshness measures match + # reality; the read path is read-only so a missing row is a + # cue to refresh, not an error. + result = await gitea.read_file(owner, repo, RFC_FILE_PATH, ref=branch) + if result is None: + # The branch might exist but be empty; check the branch + # itself before deciding whether this is 404 or 200-with-empty. + br = await gitea.get_branch(owner, repo, branch) + if br is None: + raise HTTPException(404, "Branch not found") + body, body_sha = "", "" + else: + body, body_sha = result + + # Ensure the whole-doc chat thread for the branch exists. + thread_id = _ensure_branch_chat_thread(slug, branch, viewer) + + # Sub-threads (range/paragraph) and flags scoped to this branch. + thread_rows = db.conn().execute( + """ + SELECT id, anchor_kind, anchor_payload, thread_kind, label, state, created_by, created_at + FROM threads + WHERE rfc_slug = ? AND branch_name = ? + ORDER BY id + """, + (slug, branch), + ).fetchall() + threads = [_serialize_thread(t) for t in thread_rows] + + # Visibility, contribute, grants. + vis = _branch_vis(slug, branch) + grants = _branch_grants(slug, branch) + + # Pending and resolved changes scoped to this branch. + changes_rows = db.conn().execute( + """ + SELECT id, thread_id, source_message_id, kind, state, original, proposed, reason, + was_edited_before_accept, stale_since, acted_by, acted_at, commit_sha, created_at + FROM changes + WHERE rfc_slug = ? AND branch_name = ? + ORDER BY id + """, + (slug, branch), + ).fetchall() + changes = [_serialize_change(c) for c in changes_rows] + + # Branch metadata for the breadcrumb / header. + creator = _branch_creator(slug, branch) + capabilities = _capabilities(rfc, slug, branch, viewer, creator) + + return { + "slug": slug, + "title": rfc["title"], + "branch_name": branch, + "body": body, + "body_sha": body_sha, + "main_thread_id": thread_id, + "threads": threads, + "changes": changes, + "visibility": vis, + "grants": grants, + "creator": creator, + "capabilities": capabilities, + } + + # ------------------------------------------------------------------- + # §17: POST /api/rfcs//branches/main/promote-to-branch + # The §8.14 "Start Contributing on main" gesture. + # ------------------------------------------------------------------- + + @router.post("/api/rfcs/{slug}/branches/main/promote-to-branch") + async def promote_to_branch(slug: str, body: PromoteToBranchBody, request: Request) -> dict[str, Any]: + viewer = auth.require_contributor(request) + rfc = _require_active_rfc(slug) + owner, repo = _owner_repo(rfc) + new_branch = (body.branch_name or "").strip() + if not new_branch: + new_branch = _auto_branch_name(viewer.gitea_login) + _validate_branch_name(new_branch) + try: + await bot.cut_branch_from_main( + viewer.as_actor(), + owner=owner, + repo=repo, + new_branch=new_branch, + slug=slug, + ) + except GiteaError as e: + raise HTTPException(502, f"Gitea: {e.detail}") + + # Per §8.14, re-anchor any pending main-scoped changes by + # mutating branch_name. They haven't been acted on yet, so + # there is no audit trail to corrupt; `source_message_id` + # continues to point at messages in main's chat — the schema + # permits the cross-branch reference and the UI labels it as + # "from a conversation on main." + db.conn().execute( + """ + UPDATE changes + SET branch_name = ? + WHERE rfc_slug = ? AND branch_name = 'main' AND state = 'pending' + """, + (new_branch, slug), + ) + + # Set the branch creator's default visibility (the spec + # defaults already match, but we materialize the row so the + # creator's identity travels with the branch). + _ensure_branch_vis(slug, new_branch, creator_user_id=viewer.user_id) + + # Make the cache aware immediately so the breadcrumb reflects + # the new branch without waiting for the webhook hop. + await cache.refresh_rfc_repo(config, gitea, slug) + + return {"branch_name": new_branch, "slug": slug} + + # ------------------------------------------------------------------- + # §17 / §8.9: accept / decline / reask a change + # ------------------------------------------------------------------- + + @router.post("/api/rfcs/{slug}/branches/{branch}/changes/{change_id}/accept") + async def accept_change( + slug: str, + branch: str, + change_id: int, + body: AcceptChangeBody, + request: Request, + ) -> dict[str, Any]: + viewer = auth.require_contributor(request) + rfc = _require_active_rfc(slug) + _require_can_contribute(slug, branch, viewer) + row = _require_pending_change(slug, branch, change_id) + if row["kind"] != "ai": + raise HTTPException(409, "Manual changes are accepted via manual-flush") + + # Fetch current branch body and locate the change's `original`. + owner, repo = _owner_repo(rfc) + fetched = await gitea.read_file(owner, repo, RFC_FILE_PATH, ref=branch) + if fetched is None: + raise HTTPException(409, "Branch RFC.md not found") + current_body, prior_sha = fetched + + original = row["original"] + # §8.9: the fallback for ambiguous ranges — the `original` text + # appearing in more than one place — is to refuse the apply. + occurrences = current_body.count(original) + if occurrences == 0: + if not body.force_apply_stale: + # Per §8.11: mark stale and refuse. The contributor can + # re-ask, or force-apply if they judge it still applicable. + db.conn().execute( + "UPDATE changes SET stale_since = COALESCE(stale_since, datetime('now')) WHERE id = ?", + (change_id,), + ) + raise HTTPException(409, "Change is stale — original text no longer in document") + # force-apply path: append the proposed text at the end of + # the document as a coarse fallback. The contributor's + # explicit consent (force_apply_stale) is the gate. + new_body = current_body.rstrip() + "\n\n" + body.proposed.strip() + "\n" + elif occurrences > 1: + raise HTTPException(409, "Change cannot be auto-applied: original text appears multiple times") + else: + new_body = current_body.replace(original, body.proposed, 1) + + try: + sha = await bot.commit_accepted_change( + viewer.as_actor(), + owner=owner, + repo=repo, + branch=branch, + file_path=RFC_FILE_PATH, + new_content=new_body, + prior_sha=prior_sha, + change_id=change_id, + original=original, + proposed=body.proposed, + ai_proposed=row["proposed"] if body.was_edited_before_accept else None, + reason=row["reason"] or "", + source_message_id=row["source_message_id"], + slug=slug, + ) + except GiteaError as e: + raise HTTPException(502, f"Gitea: {e.detail}") + + db.conn().execute( + """ + UPDATE changes + SET state = 'accepted', + proposed = ?, + was_edited_before_accept = ?, + acted_by = ?, acted_at = datetime('now'), + commit_sha = ?, + stale_since = NULL + WHERE id = ? + """, + ( + body.proposed, + 1 if body.was_edited_before_accept else 0, + viewer.user_id, + sha, + change_id, + ), + ) + + # Per §8.11: a successful manual or AI commit changes the + # document; mark any pending AI proposals whose anchor no + # longer locates as stale. + chat_layer.mark_stale_overlapping(rfc_slug=slug, branch_name=branch, new_body=new_body) + await cache.refresh_rfc_repo(config, gitea, slug) + + return {"ok": True, "commit_sha": sha, "change_id": change_id} + + @router.post("/api/rfcs/{slug}/branches/{branch}/changes/{change_id}/decline") + async def decline_change(slug: str, branch: str, change_id: int, request: Request) -> dict[str, Any]: + viewer = auth.require_contributor(request) + _require_active_rfc(slug) + _require_can_contribute(slug, branch, viewer) + row = _require_pending_change(slug, branch, change_id) + if row["kind"] != "ai": + raise HTTPException(409, "Manual changes are declined via manual-flush revert") + # Per §8.9: decline is not a commit. The card persists with + # state='declined' as evidence. + db.conn().execute( + """ + UPDATE changes + SET state = 'declined', acted_by = ?, acted_at = datetime('now') + WHERE id = ? + """, + (viewer.user_id, change_id), + ) + return {"ok": True, "change_id": change_id} + + @router.post("/api/rfcs/{slug}/branches/{branch}/changes/{change_id}/reask") + async def reask_change(slug: str, branch: str, change_id: int, request: Request) -> dict[str, Any]: + """Per §8.11: re-prompt the AI against the current text to + regenerate a proposal anchored to the new phrasing. The old + row stays for audit; the new row lands when the streaming + turn completes. + + For Slice 2 the reask is a synchronous, non-streaming call + that returns the new change ids. The richer "streams the + regeneration into the originating thread" version lands when + the per-thread SSE chat surface needs it across more flows. + """ + viewer = auth.require_contributor(request) + rfc = _require_active_rfc(slug) + _require_can_contribute(slug, branch, viewer) + row = _require_change(slug, branch, change_id) + if row["kind"] != "ai": + raise HTTPException(409, "Only AI changes can be reasked") + thread_id = row["thread_id"] + if thread_id is None: + raise HTTPException(409, "Change has no originating thread") + if not providers: + raise HTTPException(503, "No AI providers configured") + + owner, repo = _owner_repo(rfc) + fetched = await gitea.read_file(owner, repo, RFC_FILE_PATH, ref=branch) + body_text = fetched[0] if fetched else "" + + provider = next(iter(providers.values())) + system = chat_layer.build_system_prompt(title=rfc["title"], body=body_text) + history = chat_layer.build_history(thread_id) + reask_prompt = ( + "The earlier proposal's `` text no longer matches the document — " + "the contributor has edited that passage. Please regenerate your proposal " + "anchored to the current phrasing. Earlier :\n\n" + f"{row['original']}\n\nEarlier :\n\n{row['proposed']}" + ) + user_id = chat_layer.append_user_message( + thread_id=thread_id, author_user_id=viewer.user_id, text=reask_prompt, quote=None + ) + assistant_id = chat_layer.append_assistant_placeholder( + thread_id=thread_id, model_id=default_model + ) + + text = provider.send(system, history + [{"role": "user", "content": reask_prompt}]) + chat_layer.finalize_assistant_message(message_id=assistant_id, text=text) + parsed = chat_layer.parse_changes(text) + new_ids = chat_layer.materialize_changes( + rfc_slug=slug, + branch_name=branch, + thread_id=thread_id, + source_message_id=assistant_id, + parsed=parsed, + ) + return { + "ok": True, + "user_message_id": user_id, + "assistant_message_id": assistant_id, + "new_change_ids": new_ids, + "assistant_text": text, + } + + # ------------------------------------------------------------------- + # §17 / §8.11 / §10.6: manual-edit flush + # ------------------------------------------------------------------- + + @router.post("/api/rfcs/{slug}/branches/{branch}/manual-flush") + async def manual_flush(slug: str, branch: str, body: ManualFlushBody, request: Request) -> dict[str, Any]: + viewer = auth.require_contributor(request) + rfc = _require_active_rfc(slug) + _require_can_contribute(slug, branch, viewer) + owner, repo = _owner_repo(rfc) + + fetched = await gitea.read_file(owner, repo, RFC_FILE_PATH, ref=branch) + if fetched is None: + raise HTTPException(409, "Branch RFC.md not found") + prior_body, prior_sha = fetched + if prior_body == body.new_content: + return {"ok": True, "noop": True} + + # Per §8.11: the manual change is materialized as a `changes` + # row first (state='accepted' on flush, with the commit_sha + # backfilled), so the resolved card binds 1:1 to the commit. + cur = db.conn().execute( + """ + INSERT INTO changes + (rfc_slug, branch_name, kind, state, original, proposed, reason, + acted_by, acted_at) + VALUES (?, ?, 'manual', 'accepted', ?, ?, ?, ?, datetime('now')) + """, + ( + slug, + branch, + prior_body, + body.new_content, + f"manual edit: {body.paragraph_count} paragraph(s) changed", + viewer.user_id, + ), + ) + change_id = cur.lastrowid + try: + sha = await bot.commit_manual_flush( + viewer.as_actor(), + owner=owner, + repo=repo, + branch=branch, + file_path=RFC_FILE_PATH, + new_content=body.new_content, + prior_sha=prior_sha, + change_id=change_id, + paragraph_count=body.paragraph_count, + slug=slug, + ) + except GiteaError as e: + # Roll back the changes row so a failed commit doesn't + # leave a phantom resolved card in the panel. + db.conn().execute("DELETE FROM changes WHERE id = ?", (change_id,)) + raise HTTPException(502, f"Gitea: {e.detail}") + + db.conn().execute( + "UPDATE changes SET commit_sha = ? WHERE id = ?", + (sha, change_id), + ) + + # Per §10.6: every manual flush drops a system-author message + # into the branch chat. Even before the PR exists, the chat is + # the canonical evidence timeline and a silent diff shift + # would corrupt it. + main_thread_id = _ensure_branch_chat_thread(slug, branch, viewer) + chat_layer.append_system_message( + thread_id=main_thread_id, + text=f"manual edit: {body.paragraph_count} paragraph(s) changed", + ) + + chat_layer.mark_stale_overlapping(rfc_slug=slug, branch_name=branch, new_body=body.new_content) + await cache.refresh_rfc_repo(config, gitea, slug) + + return {"ok": True, "commit_sha": sha, "change_id": change_id} + + # ------------------------------------------------------------------- + # §17 / §11: visibility + contribute + grants + # ------------------------------------------------------------------- + + @router.post("/api/rfcs/{slug}/branches/{branch}/visibility") + async def set_branch_visibility(slug: str, branch: str, body: VisibilityBody, request: Request) -> dict[str, Any]: + viewer = auth.require_contributor(request) + rfc = _require_active_rfc(slug) + creator = _branch_creator(slug, branch) + _require_branch_owner(rfc, viewer, creator) + current = _branch_vis(slug, branch) + read_public = body.read_public if body.read_public is not None else current["read_public"] + contribute_mode = body.contribute_mode or current["contribute_mode"] + db.conn().execute( + """ + INSERT INTO branch_visibility (rfc_slug, branch_name, read_public, contribute_mode) + VALUES (?, ?, ?, ?) + ON CONFLICT(rfc_slug, branch_name) DO UPDATE SET + read_public = excluded.read_public, + contribute_mode = excluded.contribute_mode + """, + (slug, branch, 1 if read_public else 0, contribute_mode), + ) + return {"ok": True, "visibility": _branch_vis(slug, branch)} + + @router.post("/api/rfcs/{slug}/branches/{branch}/grants") + async def add_branch_grant(slug: str, branch: str, body: GrantBody, request: Request) -> dict[str, Any]: + viewer = auth.require_contributor(request) + rfc = _require_active_rfc(slug) + creator = _branch_creator(slug, branch) + _require_branch_owner(rfc, viewer, creator) + grantee = db.conn().execute( + "SELECT id FROM users WHERE gitea_login = ?", (body.grantee_gitea_login,) + ).fetchone() + if not grantee: + raise HTTPException(404, f"User '{body.grantee_gitea_login}' has no account in this app") + db.conn().execute( + """ + INSERT OR IGNORE INTO branch_contribute_grants + (rfc_slug, branch_name, grantee_user_id, granted_by) + VALUES (?, ?, ?, ?) + """, + (slug, branch, grantee["id"], viewer.user_id), + ) + return {"ok": True, "grants": _branch_grants(slug, branch)} + + @router.delete("/api/rfcs/{slug}/branches/{branch}/grants/{grantee_login}") + async def revoke_branch_grant(slug: str, branch: str, grantee_login: str, request: Request) -> dict[str, Any]: + viewer = auth.require_contributor(request) + rfc = _require_active_rfc(slug) + creator = _branch_creator(slug, branch) + _require_branch_owner(rfc, viewer, creator) + grantee = db.conn().execute( + "SELECT id FROM users WHERE gitea_login = ?", (grantee_login,) + ).fetchone() + if grantee: + db.conn().execute( + "DELETE FROM branch_contribute_grants WHERE rfc_slug = ? AND branch_name = ? AND grantee_user_id = ?", + (slug, branch, grantee["id"]), + ) + return {"ok": True, "grants": _branch_grants(slug, branch)} + + # ------------------------------------------------------------------- + # §17 / §8.12 / §8.13: threads + # ------------------------------------------------------------------- + + @router.get("/api/rfcs/{slug}/branches/{branch}/threads") + async def list_branch_threads(slug: str, branch: str, request: Request) -> dict[str, Any]: + viewer = auth.current_user(request) + _require_active_rfc(slug) + if not _can_read_branch(slug, branch, viewer): + raise HTTPException(403, "Branch is private") + rows = db.conn().execute( + """ + SELECT id, anchor_kind, anchor_payload, thread_kind, label, state, + created_by, created_at, resolved_at, resolved_by + FROM threads + WHERE rfc_slug = ? AND branch_name = ? + ORDER BY id + """, + (slug, branch), + ).fetchall() + return {"items": [_serialize_thread(r) for r in rows]} + + @router.post("/api/rfcs/{slug}/branches/{branch}/threads") + async def create_branch_thread(slug: str, branch: str, body: ThreadCreateBody, request: Request) -> dict[str, Any]: + viewer = auth.require_contributor(request) + _require_active_rfc(slug) + if body.thread_kind == "flag" and not body.label: + raise HTTPException(422, "Flag threads require a label") + cur = db.conn().execute( + """ + INSERT INTO threads (rfc_slug, branch_name, anchor_kind, anchor_payload, + thread_kind, label, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + slug, + branch, + body.anchor_kind, + json.dumps(body.anchor_payload) if body.anchor_payload else None, + body.thread_kind, + body.label, + viewer.user_id, + ), + ) + thread_id = cur.lastrowid + message_id = None + if body.message and body.thread_kind == "chat": + message_id = chat_layer.append_user_message( + thread_id=thread_id, author_user_id=viewer.user_id, text=body.message, quote=None + ) + return {"thread_id": thread_id, "message_id": message_id} + + @router.get("/api/rfcs/{slug}/branches/{branch}/threads/{thread_id}/messages") + async def get_thread_messages(slug: str, branch: str, thread_id: int, request: Request) -> dict[str, Any]: + viewer = auth.current_user(request) + _require_active_rfc(slug) + if not _can_read_branch(slug, branch, viewer): + raise HTTPException(403, "Branch is private") + thread = _require_thread(slug, branch, thread_id) + rows = db.conn().execute( + """ + SELECT m.id, m.role, m.author_user_id, u.gitea_login as author_login, + u.display_name as author_display, m.model_id, m.text, m.quote, m.created_at + FROM thread_messages m + LEFT JOIN users u ON u.id = m.author_user_id + WHERE m.thread_id = ? + ORDER BY m.id + """, + (thread_id,), + ).fetchall() + return { + "thread": _serialize_thread(thread), + "messages": [_serialize_message(r) for r in rows], + } + + @router.post("/api/rfcs/{slug}/branches/{branch}/threads/{thread_id}/messages") + async def post_thread_message( + slug: str, branch: str, thread_id: int, body: ThreadMessageBody, request: Request + ) -> dict[str, Any]: + viewer = auth.require_contributor(request) + _require_active_rfc(slug) + _require_thread(slug, branch, thread_id) + # Posting in a branch chat does NOT require contribute access — + # §8.4 / §11.4: chat visibility follows read visibility, and + # posting requires contributor + read. Anonymous is gated by + # require_contributor already. + if not _can_read_branch(slug, branch, viewer): + raise HTTPException(403, "Branch is private") + message_id = chat_layer.append_user_message( + thread_id=thread_id, + author_user_id=viewer.user_id, + text=body.text, + quote=body.quote, + ) + return {"ok": True, "message_id": message_id} + + @router.post("/api/rfcs/{slug}/branches/{branch}/threads/{thread_id}/resolve") + async def resolve_thread(slug: str, branch: str, thread_id: int, request: Request) -> dict[str, Any]: + viewer = auth.require_contributor(request) + rfc = _require_active_rfc(slug) + thread = _require_thread(slug, branch, thread_id) + creator = _branch_creator(slug, branch) + if not _can_resolve_thread(rfc, thread, creator, viewer): + raise HTTPException(403, "Only the thread creator, the branch creator, an RFC owner/arbiter, or an app admin/owner may resolve") + db.conn().execute( + """ + UPDATE threads SET state = 'resolved', resolved_by = ?, resolved_at = datetime('now') + WHERE id = ? + """, + (viewer.user_id, thread_id), + ) + return {"ok": True, "thread_id": thread_id} + + # ------------------------------------------------------------------- + # §17 / §18 carryover: SSE-streaming chat turn on a thread + # ------------------------------------------------------------------- + + @router.post("/api/rfcs/{slug}/branches/{branch}/threads/{thread_id}/chat") + async def stream_chat_turn( + slug: str, branch: str, thread_id: int, body: ChatTurnBody, request: Request + ): + viewer = auth.require_contributor(request) + rfc = _require_active_rfc(slug) + thread = _require_thread(slug, branch, thread_id) + if not _can_read_branch(slug, branch, viewer): + raise HTTPException(403, "Branch is private") + if not providers: + raise HTTPException(503, "No AI providers configured") + model_key = body.model if body.model in providers else default_model + provider = providers[model_key] + + # Fetch the live branch body so the prompt is anchored to + # what's in Gitea right now, not the cache. + owner, repo = _owner_repo(rfc) + fetched = await gitea.read_file(owner, repo, RFC_FILE_PATH, ref=branch) + body_text = fetched[0] if fetched else "" + + # Per §8.12: when a chat turn carries a quote (the selection), + # the model needs to see the quote alongside the document. + prompt_text = body.text + if body.quote: + prompt_text = f'The contributor has selected this passage:\n"{body.quote}"\n\n---\n\n{body.text}' + + user_message_id = chat_layer.append_user_message( + thread_id=thread_id, author_user_id=viewer.user_id, text=body.text, quote=body.quote + ) + assistant_message_id = chat_layer.append_assistant_placeholder( + thread_id=thread_id, model_id=model_key + ) + + system = chat_layer.build_system_prompt(title=rfc["title"], body=body_text) + # History is every prior user/assistant row strictly before the + # one we just inserted; the orchestrator appends the current + # user message itself when calling the provider. + rows = db.conn().execute( + """ + SELECT role, text FROM thread_messages + WHERE thread_id = ? AND id < ? AND role IN ('user', 'assistant') + ORDER BY id + """, + (thread_id, user_message_id), + ).fetchall() + history = [{"role": r["role"], "content": r["text"]} for r in rows] + + async def event_stream(): + async for chunk in chat_layer.stream_assistant_turn( + provider=provider, + system_prompt=system, + history=history, + user_message=prompt_text, + thread_id=thread_id, + rfc_slug=slug, + branch_name=branch, + assistant_message_id=assistant_message_id, + ): + yield chunk + + # Per §8.4 the response includes the assistant's message id so + # the client can bind the streamed text to a chat row that + # already exists. + headers = { + "X-Assistant-Message-Id": str(assistant_message_id), + "X-User-Message-Id": str(user_message_id), + "Cache-Control": "no-cache", + } + return StreamingResponse(event_stream(), media_type="text/event-stream", headers=headers) + + # ------------------------------------------------------------------ + # Permission + state helpers (closures, share `config` etc.) + # ------------------------------------------------------------------ + + def _require_active_rfc(slug: str): + row = db.conn().execute("SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone() + if row is None: + raise HTTPException(404, "RFC not found") + if row["state"] != "active": + raise HTTPException(409, f"RFC is {row['state']}, not active — Slice 4 owns super-draft edits") + if not row["repo"]: + raise HTTPException(409, "RFC has no repo") + return row + + def _owner_repo(rfc) -> tuple[str, str]: + owner, repo = rfc["repo"].split("/", 1) + return owner, repo + + def _ensure_branch_chat_thread(slug: str, branch: str, viewer) -> int: + """Per §8.12: every branch has a default whole-doc chat thread. + Create it lazily on first read. The created_by is null when an + anonymous viewer triggers creation — the thread is structurally + owned by the branch, not by whoever opened the view.""" + row = db.conn().execute( + """ + SELECT id FROM threads + WHERE rfc_slug = ? AND branch_name = ? + AND anchor_kind = 'whole-doc' AND thread_kind = 'chat' + ORDER BY id LIMIT 1 + """, + (slug, branch), + ).fetchone() + if row: + return row["id"] + cur = db.conn().execute( + """ + INSERT INTO threads (rfc_slug, branch_name, anchor_kind, thread_kind, label, created_by) + VALUES (?, ?, 'whole-doc', 'chat', NULL, ?) + """, + (slug, branch, viewer.user_id if viewer else None), + ) + return cur.lastrowid + + def _ensure_branch_vis(slug: str, branch: str, *, creator_user_id: int) -> None: + """Materialize the §11.1 / §6.4 defaults row when a branch is + created. The creator identity is recovered separately by joining + against the `actions` log per §15.9. + """ + del creator_user_id # creator is sourced from actions log + db.conn().execute( + """ + INSERT OR IGNORE INTO branch_visibility (rfc_slug, branch_name, read_public, contribute_mode) + VALUES (?, ?, 1, 'just-me') + """, + (slug, branch), + ) + + def _branch_vis(slug: str, branch: str) -> dict: + row = db.conn().execute( + "SELECT read_public, contribute_mode FROM branch_visibility WHERE rfc_slug = ? AND branch_name = ?", + (slug, branch), + ).fetchone() + if row: + return {"read_public": bool(row["read_public"]), "contribute_mode": row["contribute_mode"]} + # §11.1 / §6.4 defaults. + return {"read_public": True, "contribute_mode": "just-me"} + + def _branch_grants(slug: str, branch: str) -> list[dict]: + rows = db.conn().execute( + """ + SELECT u.gitea_login, u.display_name, g.granted_at + FROM branch_contribute_grants g + JOIN users u ON u.id = g.grantee_user_id + WHERE g.rfc_slug = ? AND g.branch_name = ? + ORDER BY g.granted_at + """, + (slug, branch), + ).fetchall() + return [{"gitea_login": r["gitea_login"], "display_name": r["display_name"], "granted_at": r["granted_at"]} for r in rows] + + def _branch_creator(slug: str, branch: str) -> str | None: + """Per §15.9: the underlying-actor-not-bot rule applies to every + attribution surface. We look the creator up in the actions log.""" + if branch == "main": + return None + row = db.conn().execute( + """ + SELECT on_behalf_of FROM actions + WHERE action_kind = 'create_branch' AND rfc_slug = ? AND branch_name = ? + ORDER BY id LIMIT 1 + """, + (slug, branch), + ).fetchone() + return row["on_behalf_of"] if row else None + + def _can_read_branch(slug: str, branch: str, viewer) -> bool: + """Per §11.1: branches default read_public=true; the creator + and owners/arbiters can still read a private branch.""" + if branch == "main": + return True + vis = _branch_vis(slug, branch) + if vis["read_public"]: + return True + if viewer is None: + return False + if viewer.role in ("owner", "admin"): + return True + creator = _branch_creator(slug, branch) + if creator and viewer.gitea_login == creator: + return True + rfc = db.conn().execute("SELECT owners_json, arbiters_json FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone() + if rfc: + owners = json.loads(rfc["owners_json"] or "[]") + arbiters = json.loads(rfc["arbiters_json"] or "[]") + if viewer.gitea_login in owners or viewer.gitea_login in arbiters: + return True + # Explicit grant (used for the §11.4 "specific" contribute case; + # grant-ees inherit read on the §11.1 default-private branch). + row = db.conn().execute( + """ + SELECT 1 FROM branch_contribute_grants g + WHERE g.rfc_slug = ? AND g.branch_name = ? AND g.grantee_user_id = ? + """, + (slug, branch, viewer.user_id), + ).fetchone() + return row is not None + + def _can_contribute(rfc, slug: str, branch: str, viewer) -> bool: + """§6.4 contribute_mode + §6.3 per-RFC authority + §6.1 admin/owner.""" + if viewer is None: + return False + if branch == "main": + return False # main is read-only per §8.3; PRs are the only path + if viewer.role in ("owner", "admin"): + return True + owners = json.loads(rfc["owners_json"] or "[]") + arbiters = json.loads(rfc["arbiters_json"] or "[]") + if viewer.gitea_login in owners or viewer.gitea_login in arbiters: + return True + creator = _branch_creator(slug, branch) + if creator and viewer.gitea_login == creator: + return True + vis = _branch_vis(slug, branch) + if vis["contribute_mode"] == "any-contributor": + return True + if vis["contribute_mode"] == "specific": + row = db.conn().execute( + """ + SELECT 1 FROM branch_contribute_grants + WHERE rfc_slug = ? AND branch_name = ? AND grantee_user_id = ? + """, + (slug, branch, viewer.user_id), + ).fetchone() + return row is not None + return False + + def _require_can_contribute(slug: str, branch: str, viewer) -> None: + rfc = db.conn().execute( + "SELECT owners_json, arbiters_json FROM cached_rfcs WHERE slug = ?", (slug,) + ).fetchone() + if not _can_contribute(rfc, slug, branch, viewer): + raise HTTPException(403, "You do not have contribute access to this branch") + + def _require_branch_owner(rfc, viewer, creator: str | None) -> None: + """The set who can flip visibility / add grants: branch creator, + owners/arbiters, and app admins/owners per §11.1, §11.2, §6.3.""" + if viewer.role in ("owner", "admin"): + return + owners = json.loads(rfc["owners_json"] or "[]") + arbiters = json.loads(rfc["arbiters_json"] or "[]") + if viewer.gitea_login in owners or viewer.gitea_login in arbiters: + return + if creator and viewer.gitea_login == creator: + return + raise HTTPException(403, "Only the branch creator, an RFC owner/arbiter, or an admin/owner may change branch settings") + + def _capabilities(rfc, slug: str, branch: str, viewer, creator: str | None) -> dict: + return { + "can_read": _can_read_branch(slug, branch, viewer), + "can_contribute": _can_contribute(rfc, slug, branch, viewer) if viewer else False, + "can_change_branch_settings": viewer is not None and ( + viewer.role in ("owner", "admin") + or (creator is not None and viewer.gitea_login == creator) + or viewer.gitea_login in (json.loads(rfc["owners_json"] or "[]") + json.loads(rfc["arbiters_json"] or "[]")) + ), + "is_anonymous": viewer is None, + } + + def _branch_summary(slug: str, br, viewer) -> dict: + return { + "name": br["branch_name"], + "head_sha": br["head_sha"], + "state": br["state"], + "last_commit_at": br["last_commit_at"], + "pinned": bool(br["pinned"]), + "creator": _branch_creator(slug, br["branch_name"]), + "visibility": _branch_vis(slug, br["branch_name"]), + } + + def _require_change(slug: str, branch: str, change_id: int): + row = db.conn().execute( + "SELECT * FROM changes WHERE id = ? AND rfc_slug = ? AND branch_name = ?", + (change_id, slug, branch), + ).fetchone() + if not row: + raise HTTPException(404, "Change not found on this branch") + return row + + def _require_pending_change(slug: str, branch: str, change_id: int): + row = _require_change(slug, branch, change_id) + if row["state"] != "pending": + raise HTTPException(409, f"Change is already {row['state']}") + return row + + def _require_thread(slug: str, branch: str, thread_id: int): + row = db.conn().execute( + "SELECT * FROM threads WHERE id = ? AND rfc_slug = ? AND branch_name = ?", + (thread_id, slug, branch), + ).fetchone() + if not row: + raise HTTPException(404, "Thread not found on this branch") + return row + + def _can_resolve_thread(rfc, thread, creator: str | None, viewer) -> bool: + if viewer is None: + return False + if viewer.role in ("owner", "admin"): + return True + owners = json.loads(rfc["owners_json"] or "[]") + arbiters = json.loads(rfc["arbiters_json"] or "[]") + if viewer.gitea_login in owners or viewer.gitea_login in arbiters: + return True + if creator and viewer.gitea_login == creator: + return True + if thread["created_by"] == viewer.user_id: + return True + return False + + return router + + +# --------------------------------------------------------------------------- +# Serialization helpers (module-level for clarity) +# --------------------------------------------------------------------------- + + +def _serialize_thread(row) -> dict[str, Any]: + payload = row["anchor_payload"] + try: + anchor = json.loads(payload) if payload else None + except Exception: + anchor = None + return { + "id": row["id"], + "anchor_kind": row["anchor_kind"], + "anchor_payload": anchor, + "thread_kind": row["thread_kind"], + "label": row["label"], + "state": row["state"], + "created_by": row["created_by"], + "created_at": row["created_at"], + "resolved_at": row["resolved_at"] if "resolved_at" in row.keys() else None, + "resolved_by": row["resolved_by"] if "resolved_by" in row.keys() else None, + } + + +def _serialize_change(row) -> dict[str, Any]: + return { + "id": row["id"], + "thread_id": row["thread_id"], + "source_message_id": row["source_message_id"], + "kind": row["kind"], + "state": row["state"], + "original": row["original"], + "proposed": row["proposed"], + "reason": row["reason"], + "was_edited_before_accept": bool(row["was_edited_before_accept"]), + "stale_since": row["stale_since"], + "acted_by": row["acted_by"], + "acted_at": row["acted_at"], + "commit_sha": row["commit_sha"], + "created_at": row["created_at"], + } + + +def _serialize_message(row) -> dict[str, Any]: + return { + "id": row["id"], + "role": row["role"], + "author_user_id": row["author_user_id"], + "author_login": row["author_login"], + "author_display": row["author_display"], + "model_id": row["model_id"], + "text": row["text"], + "quote": row["quote"], + "created_at": row["created_at"], + } + + +# --------------------------------------------------------------------------- +# Branch name validation + auto-generation per §8.14 +# --------------------------------------------------------------------------- + +_BRANCH_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9._\-/]*[a-z0-9]$") + + +def _validate_branch_name(name: str) -> None: + # §8.14: "exact format is an implementation detail." We accept Git's + # standard ref-friendly subset and reject anything with whitespace, + # leading/trailing punctuation, or path components Gitea would refuse. + if len(name) > 120 or not _BRANCH_NAME_RE.match(name): + raise HTTPException(422, "Branch name must be lowercase alphanumerics, hyphens, dots, slashes") + if name == "main" or name.startswith(("propose/", "edit/", "claim/", "metadata/")): + raise HTTPException(422, "Branch name conflicts with a reserved prefix") + + +def _auto_branch_name(login: str) -> str: + # Per §8.14: "auto-generated value (user-renamable); the exact + # format is an implementation detail." We use `-draft-` + # — no slash so FastAPI's default {branch} path segment matches — + # which keeps the branch's origin legible in the Git log without + # depending on the `actions` join to render it. Users who type + # their own name and want a slash can still do so; the + # {branch:path}-tolerant routing in the next slice covers that. + import secrets + return f"{login.lower()}-draft-{secrets.token_hex(3)}" diff --git a/backend/app/bot.py b/backend/app/bot.py index f487a16..db03fd6 100644 --- a/backend/app/bot.py +++ b/backend/app/bot.py @@ -220,3 +220,216 @@ class Bot: rfc_slug=slug, pr_number=pr_number, ) + + # ----- Per-RFC repo: branches (§8.3, §8.14) ----- + + async def cut_branch_from_main( + self, + actor: Actor, + *, + owner: str, + repo: str, + new_branch: str, + slug: str, + from_branch: str = "main", + ) -> dict: + """Per §8.14: 'Start Contributing' on main cuts a new branch. + + Also covers the §8.3 case of a contributor wanting a fresh branch + for a piece of work. Returns the Gitea branch payload. + """ + created = await self._gitea.create_branch(owner, repo, new_branch, from_branch=from_branch) + _log( + actor, + "create_branch", + rfc_slug=slug, + branch_name=new_branch, + details={"from": from_branch, "repo": f"{owner}/{repo}"}, + ) + return created + + # ----- Per-RFC repo: per-accepted-change commits (§8.6, §8.9) ----- + + async def commit_accepted_change( + self, + actor: Actor, + *, + owner: str, + repo: str, + branch: str, + file_path: str, + new_content: str, + prior_sha: str, + change_id: int, + original: str, + proposed: str, + ai_proposed: str | None, + reason: str, + source_message_id: int | None, + slug: str, + ) -> str: + """Per §8.6: one commit per accepted change. + + The commit message subject is a short structural description; the + body carries `original`, `proposed`, and `reason` in named + sections. When the contributor edited the AI's proposal before + accepting (§8.9's `was_edited_before_accept`), the AI's original + wording is preserved under an `AI proposed:` section so the + timeline records both what was offered and what landed. + + Trailers: `Change-Id`, `Source-Message-Id` (where applicable), + and the standard `On-behalf-of:` per §6.5. + + Returns the commit SHA. + """ + subject = _subject_from_reason(reason, fallback="Accept change") + body_lines = [ + "**Original:**", + original.strip(), + "", + "**Proposed:**", + proposed.strip(), + ] + if ai_proposed is not None and ai_proposed.strip() != proposed.strip(): + body_lines += ["", "**AI proposed (edited before accept):**", ai_proposed.strip()] + if reason and reason.strip(): + body_lines += ["", "**Reason:**", reason.strip()] + body_lines += ["", f"Change-Id: {change_id}"] + if source_message_id is not None: + body_lines += [f"Source-Message-Id: {source_message_id}"] + body_lines += [_trailer(actor)] + message = subject + "\n\n" + "\n".join(body_lines).strip() + + result = await self._gitea.update_file( + owner, + repo, + file_path, + content=new_content, + sha=prior_sha, + message=message, + branch=branch, + author_name=actor.display_name, + author_email=actor.email or f"{actor.gitea_login}@users.noreply", + ) + sha = result.get("commit", {}).get("sha") or result.get("content", {}).get("sha") or "" + _log( + actor, + "accept_change", + rfc_slug=slug, + branch_name=branch, + bot_commit_sha=sha, + details={"change_id": change_id, "file_path": file_path}, + ) + return sha + + # ----- Per-RFC repo: manual-edit flushes (§8.6, §8.11) ----- + + async def commit_manual_flush( + self, + actor: Actor, + *, + owner: str, + repo: str, + branch: str, + file_path: str, + new_content: str, + prior_sha: str, + change_id: int, + paragraph_count: int, + slug: str, + ) -> str: + """Per §8.6 / §8.11: one commit per manual-edit flush window. + + Subject names the structural extent so a reviewer scanning the + log can size the change at a glance; the body carries the + change-id trailer that binds the commit to the resolved card in + the panel. + """ + plural = "" if paragraph_count == 1 else "s" + subject = f"manual edit: {paragraph_count} paragraph{plural}" + body_lines = [ + f"Change-Id: {change_id}", + _trailer(actor), + ] + message = subject + "\n\n" + "\n".join(body_lines) + result = await self._gitea.update_file( + owner, + repo, + file_path, + content=new_content, + sha=prior_sha, + message=message, + branch=branch, + author_name=actor.display_name, + author_email=actor.email or f"{actor.gitea_login}@users.noreply", + ) + sha = result.get("commit", {}).get("sha") or result.get("content", {}).get("sha") or "" + _log( + actor, + "manual_flush", + rfc_slug=slug, + branch_name=branch, + bot_commit_sha=sha, + details={"change_id": change_id, "paragraph_count": paragraph_count}, + ) + return sha + + # ----- Per-RFC repo: seeding (test/dev fixtures, future graduation) ----- + + async def ensure_rfc_repo_seed( + self, + actor: Actor, + *, + owner: str, + repo: str, + slug: str, + title: str, + body: str, + ) -> None: + """Create the per-RFC repo and seed `RFC.md` on `main` if missing. + + Slice 2 surfaces against per-RFC repos that Slice 5's graduation + flow will eventually create. Until graduation exists, this is the + seam test fixtures and ad-hoc dev workflows use to bring an RFC + repo into existence — the bot stays the only Git writer and the + seed itself enters the audit log. + """ + existing = await self._gitea.get_repo(owner, repo) + if existing is None: + await self._gitea.create_org_repo(owner, repo, description=f"RFC: {title}") + # If main has a tip already, leave it alone — the seed is idempotent. + main = await self._gitea.get_branch(owner, repo, "main") + if main is not None: + return + message = "Seed RFC.md\n\n" + _trailer(actor) + await self._gitea.create_file( + owner, + repo, + "RFC.md", + content=body, + message=message, + branch="main", + author_name=actor.display_name, + author_email=actor.email or f"{actor.gitea_login}@users.noreply", + ) + _log( + actor, + "seed_rfc_repo", + rfc_slug=slug, + branch_name="main", + details={"repo": f"{owner}/{repo}", "title": title}, + ) + + +def _subject_from_reason(reason: str, fallback: str) -> str: + """One-line commit subject derived from the change's reason. + + Truncated to 72 chars so the Git log scans cleanly. Exact length is + an implementation detail per §8.6. + """ + text = (reason or "").strip().split("\n")[0] + if not text: + return fallback + if len(text) > 72: + return text[:69].rstrip() + "…" + return text diff --git a/backend/app/cache.py b/backend/app/cache.py index ad675a9..2760ec9 100644 --- a/backend/app/cache.py +++ b/backend/app/cache.py @@ -119,6 +119,139 @@ def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str) -> None: ) +async def refresh_rfc_repo(config: Config, gitea: Gitea, slug: str) -> None: + """Mirror an active RFC's per-RFC repo into the cache. + + Reads `RFC.md` on main into `cached_rfcs.body` (per §4 #3), lists + branches into `cached_branches`, and lists open PRs into + `cached_prs` with `pr_kind='rfc_branch'`. Per §4.1 this runs in two + places: a webhook arrival for events on the per-RFC repo, and the + reconciler sweep. + """ + row = db.conn().execute( + "SELECT repo, state FROM cached_rfcs WHERE slug = ?", (slug,) + ).fetchone() + if not row or not row["repo"] or row["state"] != "active": + return + if "/" not in row["repo"]: + log.warning("refresh_rfc_repo: %s has malformed repo %r", slug, row["repo"]) + return + owner, repo = row["repo"].split("/", 1) + + # Body on main — populates the discuss-mode default surface per §8.2. + try: + result = await gitea.read_file(owner, repo, "RFC.md", ref="main") + except GiteaError as e: + log.warning("refresh_rfc_repo(%s): read_file failed: %s", slug, e) + result = None + if result is not None: + text, sha = result + db.conn().execute( + """ + UPDATE cached_rfcs + SET body = ?, body_sha = ?, last_main_commit_at = datetime('now'), + updated_at = datetime('now') + WHERE slug = ? + """, + (text, sha, slug), + ) + + # Branches — every branch the bot knows about per §11.5 / §12. + try: + branches = await gitea.list_branches(owner, repo) + except GiteaError as e: + log.warning("refresh_rfc_repo(%s): list_branches failed: %s", slug, e) + branches = [] + seen_branches: set[str] = set() + for b in branches: + name = b.get("name") or "" + if not name: + continue + seen_branches.add(name) + head_sha = (b.get("commit") or {}).get("id") or "" + last_commit_at = (b.get("commit") or {}).get("timestamp") + db.conn().execute( + """ + INSERT INTO cached_branches (rfc_slug, branch_name, head_sha, state, last_commit_at) + VALUES (?, ?, ?, 'open', ?) + ON CONFLICT(rfc_slug, branch_name) DO UPDATE SET + head_sha = excluded.head_sha, + state = CASE WHEN cached_branches.state = 'closed' THEN 'closed' ELSE 'open' END, + last_commit_at = excluded.last_commit_at + """, + (slug, name, head_sha, last_commit_at), + ) + # Mark previously-known branches that disappeared as deleted, keeping + # the row per §11.5 ("branch removed from Gitea, row remains"). + existing = { + r["branch_name"] + for r in db.conn().execute( + "SELECT branch_name FROM cached_branches WHERE rfc_slug = ? AND state != 'deleted'", + (slug,), + ) + } + for missing in existing - seen_branches: + db.conn().execute( + "UPDATE cached_branches SET state = 'deleted' WHERE rfc_slug = ? AND branch_name = ?", + (slug, missing), + ) + + # PRs on the per-RFC repo (pr_kind = 'rfc_branch'). Slice 3 owns the + # full PR surface; we mirror metadata here so the §8.1 breadcrumb + # dropdown's "1 PR" count is honest from Slice 2 onward. + repo_full = f"{owner}/{repo}" + bot_login = config.gitea_bot_user + try: + open_pulls = await gitea.list_pulls(owner, repo, state="open") + closed_pulls = await gitea.list_pulls(owner, repo, state="closed") + except GiteaError as e: + log.warning("refresh_rfc_repo(%s): list_pulls failed: %s", slug, e) + open_pulls, closed_pulls = [], [] + for pull in open_pulls + closed_pulls: + head_branch = pull.get("head", {}).get("ref", "") + state = _state_from_pull(pull) + gitea_opener = (pull.get("user") or {}).get("login") or "" + opened_by = _resolve_actor( + gitea_opener, + bot_login, + slug, + pull["number"], + pull.get("body") or "", + ) + db.conn().execute( + """ + INSERT INTO cached_prs + (rfc_slug, pr_kind, repo, pr_number, title, description, state, + opened_by, opened_at, merged_at, closed_at, + head_branch, base_branch, head_sha) + VALUES (?, 'rfc_branch', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(repo, pr_number) DO UPDATE SET + title = excluded.title, + description = excluded.description, + state = excluded.state, + opened_by = excluded.opened_by, + merged_at = excluded.merged_at, + closed_at = excluded.closed_at, + head_sha = excluded.head_sha + """, + ( + slug, + repo_full, + pull["number"], + pull.get("title") or "", + pull.get("body") or "", + state, + opened_by, + pull.get("created_at"), + pull.get("merged_at"), + pull.get("closed_at"), + head_branch, + (pull.get("base") or {}).get("ref") or "main", + (pull.get("head") or {}).get("sha"), + ), + ) + + async def refresh_meta_pulls(config: Config, gitea: Gitea) -> None: """Reconcile open meta-repo PRs into cached_prs. @@ -296,6 +429,17 @@ class Reconciler: try: await refresh_meta_repo(self._config, self._gitea) await refresh_meta_pulls(self._config, self._gitea) + # Per-RFC repos: refresh each active entry. Meta-repo refresh + # must come first so newly-graduated entries land in + # cached_rfcs before we try to reach their per-RFC repos. + active = [ + r["slug"] + for r in db.conn().execute( + "SELECT slug FROM cached_rfcs WHERE state = 'active' AND repo IS NOT NULL" + ) + ] + for slug in active: + await refresh_rfc_repo(self._config, self._gitea, slug) except Exception: log.exception("reconciler: sweep failed") else: diff --git a/backend/app/chat.py b/backend/app/chat.py new file mode 100644 index 0000000..43c4b5e --- /dev/null +++ b/backend/app/chat.py @@ -0,0 +1,321 @@ +"""SSE-streaming chat layer — §18 carryover, adapted to the §5 schema. + +The prototype kept conversation state in an in-memory `RFCChat` +keyed by a session_id. Here, history is the durable list of +`thread_messages` rows on a `threads` row, scoped to one branch (or +to a sub-thread anchored to a range or paragraph within it). The +streaming response is parsed for `` blocks per §18; each +`` becomes a `changes` row with `state='pending'` per §8.14 +the moment it is parsed, regardless of mode. + +This module exposes two seams: + - `build_history` and `build_system_prompt` — pure functions a caller + can use to assemble the LLM request without owning a provider. + - `stream_assistant_turn` — the orchestration that creates the + assistant `thread_messages` row, runs the provider's streaming + interface, parses `` blocks as they accumulate, materializes + `changes` rows on completion, and yields SSE-shaped text chunks. + +Per the §1 invariant, no Git writes happen here — chat is app data; +turning an accepted `` into a commit is a separate gesture +that goes through `bot.py`. +""" +from __future__ import annotations + +import base64 +import json +import logging +import re +from dataclasses import dataclass +from typing import AsyncIterator, Iterator + +from . import db +from .providers import BaseProvider + +log = logging.getLogger(__name__) + + +# The §18 system prompt, adapted from the prototype. The prototype's +# version assumed one RFC document loaded as context; here the document +# is the branch's RFC.md at its current tip. The selection-quote shape +# (§8.12) is wired by the caller into the user message text — not the +# system prompt — so the model sees it as part of the turn. +SYSTEM_PROMPT = """You are a participant in the Wiggleverse RFC framework — a standardization process for natural-language vocabulary that humans and machines need to share. You are collaborating with a human contributor on the RFC titled "{title}". + +The contributor's gestures may be questions, objections, sketches of new framings, or direct edit requests. Your role is to translate them into concrete proposed edits where possible. The transcript of this conversation is the durable evidence the definition was earned, so be specific and stay close to the text. + +Format each proposed change as one block: + + +exact text to replace, copied verbatim from the document +replacement text +why this change improves the document — one or two short sentences + + +Rules: +- The text must match the document character-for-character. Do not paraphrase, do not abbreviate. +- One block per distinct edit. Multiple blocks are encouraged when the contributor's input touches several passages. +- If the contributor is asking a general question or exploring an idea not yet ready to become an edit, respond in plain prose. When in doubt, lean toward proposing an edit. +- After your blocks you may add a brief conversational note. Keep it short. + +--- + +The current document: + +{body} +""" + + +# --------------------------------------------------------------------------- +# History / prompt assembly +# --------------------------------------------------------------------------- + + +def build_history(thread_id: int) -> list[dict]: + """Pull the thread's messages in chronological order, in the + {role, content} shape every provider's `send` interface expects. + System-author rows are excluded — the prompt template carries the + standing instructions; system-author messages are inline narrative + that doesn't change the model's behavior. + """ + rows = db.conn().execute( + """ + SELECT role, text FROM thread_messages + WHERE thread_id = ? AND role IN ('user', 'assistant') + ORDER BY id + """, + (thread_id,), + ).fetchall() + return [{"role": r["role"], "content": r["text"]} for r in rows] + + +def build_system_prompt(*, title: str, body: str) -> str: + return SYSTEM_PROMPT.format(title=title, body=body) + + +# --------------------------------------------------------------------------- +# parsing +# --------------------------------------------------------------------------- + +_CHANGE_RE = re.compile( + r"\s*([\s\S]*?)\s*([\s\S]*?)\s*([\s\S]*?)\s*", + re.MULTILINE, +) + + +@dataclass(frozen=True) +class ParsedChange: + original: str + proposed: str + reason: str + + +def parse_changes(text: str) -> list[ParsedChange]: + """Per §18: pull every well-formed block out of an assistant + message. Mid-stream partials are simply not matched yet; the parser + runs once on completion.""" + return [ + ParsedChange(m.group(1).strip(), m.group(2).strip(), m.group(3).strip()) + for m in _CHANGE_RE.finditer(text) + ] + + +# --------------------------------------------------------------------------- +# Persistence — turn boundaries +# --------------------------------------------------------------------------- + + +def append_user_message( + *, + thread_id: int, + author_user_id: int, + text: str, + quote: str | None, +) -> int: + cur = db.conn().execute( + """ + INSERT INTO thread_messages (thread_id, role, author_user_id, text, quote) + VALUES (?, 'user', ?, ?, ?) + """, + (thread_id, author_user_id, text, quote), + ) + return cur.lastrowid + + +def append_assistant_placeholder(*, thread_id: int, model_id: str) -> int: + cur = db.conn().execute( + """ + INSERT INTO thread_messages (thread_id, role, model_id, text) + VALUES (?, 'assistant', ?, '') + """, + (thread_id, model_id), + ) + return cur.lastrowid + + +def finalize_assistant_message(*, message_id: int, text: str) -> None: + db.conn().execute( + "UPDATE thread_messages SET text = ? WHERE id = ?", + (text, message_id), + ) + + +def append_system_message(*, thread_id: int, text: str) -> int: + """Used by §10.6 (manual-edit-flush markers), §9.3 (decline-comment + record), and any other system-narrated event that needs to live + inline in chat. role='system', author_user_id=NULL.""" + cur = db.conn().execute( + """ + INSERT INTO thread_messages (thread_id, role, text) + VALUES (?, 'system', ?) + """, + (thread_id, text), + ) + return cur.lastrowid + + +def materialize_changes( + *, + rfc_slug: str, + branch_name: str, + thread_id: int, + source_message_id: int, + parsed: list[ParsedChange], +) -> list[int]: + """Per §8.14: every block becomes a `changes` row with + state='pending' immediately, regardless of mode. Returns the new + row ids in source order.""" + ids: list[int] = [] + for ch in parsed: + cur = db.conn().execute( + """ + INSERT INTO changes + (rfc_slug, branch_name, thread_id, source_message_id, + kind, state, original, proposed, reason) + VALUES (?, ?, ?, ?, 'ai', 'pending', ?, ?, ?) + """, + (rfc_slug, branch_name, thread_id, source_message_id, ch.original, ch.proposed, ch.reason), + ) + ids.append(cur.lastrowid) + return ids + + +def mark_stale_overlapping( + *, + rfc_slug: str, + branch_name: str, + new_body: str, +) -> int: + """Per §8.11: when a manual edit changes the document such that a + pending AI proposal's `original` no longer locates, set its + `stale_since`. The contributor's action stays gated on the stale + card; state stays `pending`. + + Returns the number of rows marked stale on this call (idempotent + on re-entry — already-stale rows aren't touched twice). + """ + rows = db.conn().execute( + """ + SELECT id, original FROM changes + WHERE rfc_slug = ? AND branch_name = ? + AND kind = 'ai' AND state = 'pending' AND stale_since IS NULL + """, + (rfc_slug, branch_name), + ).fetchall() + marked = 0 + for r in rows: + original = (r["original"] or "").strip() + if original and original not in new_body: + db.conn().execute( + "UPDATE changes SET stale_since = datetime('now') WHERE id = ?", + (r["id"],), + ) + marked += 1 + return marked + + +# --------------------------------------------------------------------------- +# SSE shape — base64 chunks for binary-safe transport +# --------------------------------------------------------------------------- + + +def sse_chunk(text: str) -> str: + encoded = base64.b64encode(text.encode("utf-8")).decode("ascii") + return f"data: {encoded}\n\n" + + +def sse_event(name: str, payload: dict) -> str: + return f"event: {name}\ndata: {json.dumps(payload)}\n\n" + + +# --------------------------------------------------------------------------- +# Orchestration +# --------------------------------------------------------------------------- + + +async def stream_assistant_turn( + *, + provider: BaseProvider, + system_prompt: str, + history: list[dict], + user_message: str, + thread_id: int, + rfc_slug: str, + branch_name: str, + assistant_message_id: int, +) -> AsyncIterator[str]: + """Run the provider's streaming interface, yielding SSE-encoded + chunks. On completion, materializes `changes` rows from any + `` blocks in the assembled text and emits a trailing + `changes` event listing the new change ids. + + The user's message must already have been persisted by the caller + before this is invoked; the placeholder assistant row whose id is + `assistant_message_id` must exist too. This module's job is to + populate the assistant row's text and materialize the changes; the + caller wires it into a FastAPI StreamingResponse. + + Provider streaming is synchronous (an `Iterator[str]`) per §18; we + drain it eagerly into chunks and yield them as async strings. This + is sufficient at single-process scale (§4.2) and the streaming + impl is what the prototype shipped — re-wrapping it in a worker + thread for a future deployment shape is a one-liner if it + matters. + """ + full_text_chunks: list[str] = [] + # Hand the provider the user turn appended to history. + history_for_call = list(history) + [{"role": "user", "content": user_message}] + + def _drain() -> Iterator[str]: + try: + yield from provider.send_streaming(system_prompt, history_for_call) + except Exception as e: + log.exception("provider stream failed") + yield f"\n\n[Provider error: {e}]" + + for chunk in _drain(): + if not chunk: + continue + full_text_chunks.append(chunk) + yield sse_chunk(chunk) + + full_text = "".join(full_text_chunks) + finalize_assistant_message(message_id=assistant_message_id, text=full_text) + + parsed = parse_changes(full_text) + new_ids = materialize_changes( + rfc_slug=rfc_slug, + branch_name=branch_name, + thread_id=thread_id, + source_message_id=assistant_message_id, + parsed=parsed, + ) + yield sse_event( + "changes", + { + "message_id": assistant_message_id, + "change_ids": new_ids, + "count": len(new_ids), + }, + ) + yield "data: DONE\n\n" diff --git a/backend/app/main.py b/backend/app/main.py index 6b9ea13..01acce4 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -14,7 +14,7 @@ from fastapi import APIRouter, FastAPI, HTTPException, Request from fastapi.responses import RedirectResponse from starlette.middleware.sessions import SessionMiddleware -from . import api as api_routes, auth, cache, db, webhooks +from . import api as api_routes, auth, cache, db, providers as providers_mod, webhooks from .bot import Bot from .config import load_config from .gitea import Gitea @@ -32,13 +32,24 @@ async def lifespan(app: FastAPI): bot = Bot(gitea) reconciler = cache.Reconciler(config, gitea) + # §18 carryover: the multi-provider LLM abstraction. Provider + # construction can fail (missing key, wrong env value) — if it does, + # the rest of the app still serves; chat endpoints surface a clear + # 503 instead of crashing the process. + try: + providers = providers_mod.load_from_config(config) + except Exception: + log.exception("provider construction failed; chat will be disabled") + providers = {} + app.state.config = config app.state.gitea = gitea app.state.bot = bot app.state.reconciler = reconciler + app.state.providers = providers app.include_router(_oauth_router(config)) - app.include_router(api_routes.make_router(config, gitea, bot)) + app.include_router(api_routes.make_router(config, gitea, bot, providers)) app.include_router(webhooks.make_router(config, gitea)) reconciler.start() diff --git a/backend/app/providers.py b/backend/app/providers.py new file mode 100644 index 0000000..c4ae455 --- /dev/null +++ b/backend/app/providers.py @@ -0,0 +1,195 @@ +"""Multi-provider LLM abstraction — §18 carryover from the prototype. + +Each provider speaks a common interface — `send` and `send_streaming` — +so the chat layer in `chat.py` is provider-agnostic. Enabled providers +and their API keys are configured via env per the prototype's +`ENABLED_MODELS` contract; per §16 / §19.2, per-RFC model availability +and credential delegation are deferred until the topic is settled. +""" +from __future__ import annotations + +from typing import Iterator + + +class BaseProvider: + name: str = "base" + display_name: str = "Base" + + def send(self, system: str, history: list[dict]) -> str: + raise NotImplementedError + + def send_streaming(self, system: str, history: list[dict]) -> Iterator[str]: + raise NotImplementedError + + +# --------------------------------------------------------------------------- +# Anthropic — Claude +# --------------------------------------------------------------------------- + +class AnthropicProvider(BaseProvider): + name = "claude" + + def __init__(self, api_key: str, model: str = "claude-sonnet-4-6", display_name: str = "Claude"): + import anthropic + self.client = anthropic.Anthropic(api_key=api_key) + self.model = model + self.display_name = display_name + + def send(self, system: str, history: list[dict]) -> str: + response = self.client.messages.create( + model=self.model, + max_tokens=4096, + system=system, + messages=history, + ) + return response.content[0].text + + def send_streaming(self, system: str, history: list[dict]) -> Iterator[str]: + with self.client.messages.stream( + model=self.model, + max_tokens=4096, + system=system, + messages=history, + ) as stream: + for text in stream.text_stream: + yield text + + +# --------------------------------------------------------------------------- +# Google — Gemini +# --------------------------------------------------------------------------- + +class GeminiProvider(BaseProvider): + name = "gemini" + + def __init__(self, api_key: str, model: str = "gemini-1.5-pro", display_name: str = "Gemini"): + import google.generativeai as genai + genai.configure(api_key=api_key) + self._genai = genai + self.model_name = model + self.display_name = display_name + + def _build_model(self, system: str): + return self._genai.GenerativeModel(model_name=self.model_name, system_instruction=system) + + def _convert_history(self, history: list[dict]) -> list[dict]: + return [ + {"role": "user" if msg["role"] == "user" else "model", "parts": [msg["content"]]} + for msg in history + ] + + def send(self, system: str, history: list[dict]) -> str: + model = self._build_model(system) + prior = self._convert_history(history[:-1]) + chat = model.start_chat(history=prior) + response = chat.send_message(history[-1]["content"]) + return response.text + + def send_streaming(self, system: str, history: list[dict]) -> Iterator[str]: + model = self._build_model(system) + prior = self._convert_history(history[:-1]) + chat = model.start_chat(history=prior) + response = chat.send_message(history[-1]["content"], stream=True) + for chunk in response: + if chunk.text: + yield chunk.text + + +# --------------------------------------------------------------------------- +# OpenAI-compatible — OpenAI, Copilot, or any compatible endpoint +# --------------------------------------------------------------------------- + +class OpenAIProvider(BaseProvider): + name = "openai" + + def __init__(self, api_key: str, model: str = "gpt-4o", base_url: str | None = None, display_name: str = "Copilot"): + from openai import OpenAI + self.client = OpenAI(api_key=api_key, base_url=base_url or "https://api.openai.com/v1") + self.model = model + self.display_name = display_name + + def _messages(self, system: str, history: list[dict]) -> list[dict]: + return [{"role": "system", "content": system}] + [ + {"role": msg["role"], "content": msg["content"]} for msg in history + ] + + def send(self, system: str, history: list[dict]) -> str: + response = self.client.chat.completions.create( + model=self.model, max_tokens=4096, messages=self._messages(system, history) + ) + return response.choices[0].message.content + + def send_streaming(self, system: str, history: list[dict]) -> Iterator[str]: + stream = self.client.chat.completions.create( + model=self.model, max_tokens=4096, messages=self._messages(system, history), stream=True + ) + for chunk in stream: + delta = chunk.choices[0].delta.content + if delta: + yield delta + + +# --------------------------------------------------------------------------- +# Variants and factory — preserved from the prototype to keep the contract. +# --------------------------------------------------------------------------- + +_CLAUDE_VARIANTS: dict[str, tuple[str, str]] = { + "claude": ("claude-sonnet-4-6", "Claude"), + "claude-sonnet": ("claude-sonnet-4-6", "Claude Sonnet"), + "claude-opus": ("claude-opus-4-6", "Claude Opus"), + "claude-haiku": ("claude-haiku-4-5-20251001", "Claude Haiku"), +} + +_GEMINI_VARIANTS: dict[str, tuple[str, str]] = { + "gemini": ("gemini-1.5-pro", "Gemini"), + "gemini-pro": ("gemini-1.5-pro", "Gemini Pro"), + "gemini-flash": ("gemini-1.5-flash", "Gemini Flash"), + "gemini-2-flash": ("gemini-2.0-flash", "Gemini 2 Flash"), +} + + +def load_providers(env: dict) -> dict[str, BaseProvider]: + """Instantiate enabled providers from env — same contract as the prototype.""" + enabled = [m.strip() for m in env.get("ENABLED_MODELS", "claude").split(",") if m.strip()] + providers: dict[str, BaseProvider] = {} + + anthropic_key = env.get("ANTHROPIC_API_KEY") or "" + google_key = env.get("GOOGLE_API_KEY") or "" + openai_key = env.get("OPENAI_API_KEY") or "" + + for key in enabled: + prefix = key.upper().replace("-", "_") + if key in _CLAUDE_VARIANTS and anthropic_key: + default_model, default_name = _CLAUDE_VARIANTS[key] + providers[key] = AnthropicProvider( + api_key=anthropic_key, + model=env.get(f"{prefix}_MODEL", default_model), + display_name=env.get(f"{prefix}_DISPLAY_NAME", default_name), + ) + elif key in _GEMINI_VARIANTS and google_key: + default_model, default_name = _GEMINI_VARIANTS[key] + providers[key] = GeminiProvider( + api_key=google_key, + model=env.get(f"{prefix}_MODEL", default_model), + display_name=env.get(f"{prefix}_DISPLAY_NAME", default_name), + ) + elif key == "openai" and openai_key: + providers["openai"] = OpenAIProvider( + api_key=openai_key, + model=env.get("OPENAI_MODEL", "gpt-4o"), + base_url=env.get("OPENAI_BASE_URL"), + display_name=env.get("OPENAI_DISPLAY_NAME", "Copilot"), + ) + + return providers + + +def load_from_config(config) -> dict[str, BaseProvider]: + """Convenience adapter so callers can pass our Config dataclass directly.""" + env = { + "ENABLED_MODELS": ",".join(config.enabled_models), + "ANTHROPIC_API_KEY": config.anthropic_api_key, + "GOOGLE_API_KEY": config.google_api_key, + "OPENAI_API_KEY": config.openai_api_key, + } + return load_providers(env) diff --git a/backend/app/webhooks.py b/backend/app/webhooks.py index 041ecb9..199796d 100644 --- a/backend/app/webhooks.py +++ b/backend/app/webhooks.py @@ -10,11 +10,12 @@ from __future__ import annotations import hashlib import hmac +import json import logging from fastapi import APIRouter, Header, HTTPException, Request -from . import cache +from . import cache, db from .config import Config from .gitea import Gitea @@ -47,14 +48,25 @@ def make_router(config: Config, gitea: Gitea) -> APIRouter: if event not in EVENTS_OF_INTEREST: return {"ok": True, "ignored": event} - # Slice 1 only acts on meta-repo events; per-RFC-repo events - # land in their respective slices. The handler is generous in - # what it accepts — any meta-repo change is a cue to refresh - # the whole meta-repo cache, since the cache is small and the - # refresh is idempotent. + # Identify the originating repo. For the meta repo we refresh + # the entry cache + meta-PR cache; for a per-RFC repo we refresh + # just that repo's branches/PRs/main body. The handler stays + # generous in what it accepts — refreshes are idempotent and + # small enough that overlapping events do not pile up. try: - await cache.refresh_meta_repo(config, gitea) - await cache.refresh_meta_pulls(config, gitea) + payload = json.loads(body) if body else {} + except Exception: + payload = {} + repo_full = (payload.get("repository") or {}).get("full_name") or "" + meta_full = f"{config.gitea_org}/{config.meta_repo}" + try: + if repo_full == meta_full or not repo_full: + await cache.refresh_meta_repo(config, gitea) + await cache.refresh_meta_pulls(config, gitea) + else: + slug = _slug_for_repo(repo_full) + if slug: + await cache.refresh_rfc_repo(config, gitea, slug) except Exception: log.exception("webhook refresh failed") raise HTTPException(status_code=500, detail="Refresh failed") @@ -69,3 +81,10 @@ def _verify_signature(body: bytes, header: str, secret: str) -> bool: return False expected = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, header) + + +def _slug_for_repo(repo_full: str) -> str | None: + row = db.conn().execute( + "SELECT slug FROM cached_rfcs WHERE repo = ?", (repo_full,) + ).fetchone() + return row["slug"] if row else None diff --git a/backend/tests/test_propose_vertical.py b/backend/tests/test_propose_vertical.py index 50baaf7..f82cbbb 100644 --- a/backend/tests/test_propose_vertical.py +++ b/backend/tests/test_propose_vertical.py @@ -34,22 +34,38 @@ import pytest class FakeGitea: - """A narrow in-memory simulation of the Gitea API the slice uses.""" + """A narrow in-memory simulation of the Gitea API the slices exercise. + + Slice 2 extends the seam to cover per-RFC repos: PUT contents + (update file), POST orgs/{org}/repos (create repo), and branch + listing with commit timestamps. The simulator is intentionally + minimal — only the routes the production paths actually call. + """ def __init__(self): # files: (owner, repo, branch, path) -> {"content": str, "sha": str} self.files: dict[tuple[str, str, str, str], dict] = {} - # branches: (owner, repo) -> {branch_name -> {"sha": str}} + # branches: (owner, repo) -> {branch_name -> {"sha": str, "ts": str}} self.branches: dict[tuple[str, str], dict[str, dict]] = {} # pulls: (owner, repo) -> list[pull-dict] self.pulls: dict[tuple[str, str], list[dict]] = {} + # repos: set of (owner, repo) + self.repos: set[tuple[str, str]] = set() self._pr_counter = 0 self._commit_counter = 0 self._seed_repo("wiggleverse", "meta") def _seed_repo(self, owner, repo): - self.branches[(owner, repo)] = {"main": {"sha": "initial"}} + self.branches[(owner, repo)] = {"main": {"sha": "initial", "ts": "2026-05-23T00:00:00Z"}} self.pulls[(owner, repo)] = [] + self.repos.add((owner, repo)) + + def seed_rfc_repo(self, owner, repo, *, rfc_md_body): + """Convenience: seed a per-RFC repo with an RFC.md on main.""" + self._seed_repo(owner, repo) + sha = self._next_sha() + self.files[(owner, repo, "main", "RFC.md")] = {"content": rfc_md_body, "sha": sha} + self.branches[(owner, repo)]["main"] = {"sha": sha, "ts": "2026-05-23T00:00:00Z"} def _next_sha(self): self._commit_counter += 1 @@ -62,8 +78,29 @@ class FakeGitea: payload = json.loads(body) if body else {} # GET /repos/{owner}/{repo} - if method == "GET" and re.fullmatch(r"/repos/[^/]+/[^/]+", path): - return httpx.Response(200, json={"name": path.split("/")[-1]}) + m_repo = re.fullmatch(r"/repos/([^/]+)/([^/]+)", path) + if method == "GET" and m_repo: + owner, repo = m_repo.groups() + if (owner, repo) in self.repos: + return httpx.Response(200, json={"name": repo, "full_name": f"{owner}/{repo}"}) + return httpx.Response(404, json={"message": "not found"}) + + # POST /orgs/{org}/repos + m = re.fullmatch(r"/orgs/([^/]+)/repos", path) + if method == "POST" and m: + org = m.group(1) + name = payload["name"] + self._seed_repo(org, name) + return httpx.Response(201, json={"name": name, "full_name": f"{org}/{name}"}) + + # GET /repos/{owner}/{repo}/branches (list) + m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/branches", path) + if method == "GET" and m: + owner, repo = m.groups() + items = [] + for name, b in self.branches.get((owner, repo), {}).items(): + items.append({"name": name, "commit": {"id": b["sha"], "timestamp": b.get("ts")}}) + return httpx.Response(200, json=items) # GET /repos/{owner}/{repo}/branches/{branch} m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/branches/([^/]+)", path) @@ -126,9 +163,20 @@ class FakeGitea: content = base64.b64decode(payload["content"]).decode() sha = self._next_sha() self.files[(owner, repo, branch, fpath)] = {"content": content, "sha": sha} - self.branches[(owner, repo)][branch]["sha"] = sha + self.branches[(owner, repo)][branch] = {"sha": sha, "ts": "2026-05-23T00:00:00Z"} return httpx.Response(201, json={"commit": {"sha": sha}}) + # PUT /repos/{owner}/{repo}/contents/{path} — update_file + m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/contents/(.+)", path) + if method == "PUT" and m: + owner, repo, fpath = m.groups() + branch = payload["branch"] + content = base64.b64decode(payload["content"]).decode() + sha = self._next_sha() + self.files[(owner, repo, branch, fpath)] = {"content": content, "sha": sha} + self.branches[(owner, repo)][branch] = {"sha": sha, "ts": "2026-05-23T00:00:00Z"} + return httpx.Response(200, json={"commit": {"sha": sha}, "content": {"sha": sha}}) + # GET /repos/{owner}/{repo}/pulls?state=... m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/pulls", path) if method == "GET" and m: diff --git a/backend/tests/test_rfc_view_vertical.py b/backend/tests/test_rfc_view_vertical.py new file mode 100644 index 0000000..9df8382 --- /dev/null +++ b/backend/tests/test_rfc_view_vertical.py @@ -0,0 +1,564 @@ +"""End-to-end integration tests for the Slice 2 vertical (§8 in full). + +Reuses FakeGitea + the session-cookie forging helpers from +`test_propose_vertical.py`, extends FakeGitea with the per-RFC repo +routes Slice 2 needs (PUT contents, POST orgs/{org}/repos, seeded +RFC.md), and walks the §8 vertical end-to-end against an in-process +fake Gitea: + + * Seed an active RFC with a per-RFC repo holding RFC.md. + * GET /api/rfcs//main and /branches/ — three-column + feed against the cache + live branch read. + * POST promote-to-branch — cut a new branch from main. + * Materialize an AI-style change directly in the database (the LLM + is mocked out where possible; one separate test exercises the + chat streaming path with a fake provider injected). + * POST accept — runs the bot's commit and updates `changes` row. + * POST decline — non-commit path; row persists as evidence. + * POST manual-flush — bot commit, system message lands in branch chat. + * POST threads — create a flag, surface it on subsequent reads. + * POST visibility — flip read_public and contribute_mode. + * POST chat — fake provider returns a known block; the + response materializes a `changes` row. +""" +from __future__ import annotations + +import json +import pytest + +# Reuse the harness already proven by Slice 1. We import via the +# top-level module name (no leading dot) because pytest discovers +# `tests/` as a flat directory of test modules without an __init__.py. +from test_propose_vertical import ( # noqa: F401 — fixtures land via import + FakeGitea, + app_with_fake_gitea, + provision_user_row, + sign_in_as, + tmp_env, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def seed_active_rfc(fake: FakeGitea, *, slug: str, title: str, body: str) -> str: + """Seed an active RFC end-to-end: create the meta-repo entry, the + per-RFC repo with RFC.md on main, and the cached_rfcs row. The + real graduation flow lands in Slice 5; until it exists, this is + the test seam for "the RFC view's preconditions are met." + """ + from app import db + import yaml + + repo_full = f"wiggleverse/rfc-0001-{slug}" + owner, repo = repo_full.split("/", 1) + fake.seed_rfc_repo(owner, repo, rfc_md_body=body) + + # Meta-repo entry — what the cache would mirror after graduation. + fm = { + "slug": slug, + "title": title, + "state": "active", + "id": "RFC-0001", + "repo": repo_full, + "proposed_by": "alice", + "proposed_at": "2026-05-01", + "graduated_at": "2026-05-22", + "graduated_by": "ben", + "owners": ["alice"], + "arbiters": ["ben"], + "tags": ["identity"], + } + entry_text = "---\n" + yaml.safe_dump(fm, sort_keys=False).rstrip() + "\n---\n" + sha = fake._next_sha() + fake.files[("wiggleverse", "meta", "main", f"rfcs/{slug}.md")] = {"content": entry_text, "sha": sha} + + # Write cached_rfcs row directly — the reconciler would also write + # this on its next sweep, but the test seam avoids the extra hop. + db.conn().execute( + """ + INSERT OR REPLACE INTO cached_rfcs + (slug, title, state, rfc_id, repo, proposed_by, proposed_at, + graduated_at, graduated_by, owners_json, arbiters_json, tags_json, + body, body_sha, last_main_commit_at, last_entry_commit_at) + VALUES (?, ?, 'active', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) + """, + ( + slug, + title, + "RFC-0001", + repo_full, + "alice", + "2026-05-01", + "2026-05-22", + "ben", + json.dumps(["alice"]), + json.dumps(["ben"]), + json.dumps(["identity"]), + body, + sha, + ), + ) + # Seed cached_branches for main, since the reconciler hasn't necessarily + # run yet inside the test client's lifespan. The webhook+reconciler + # path is what writes this in production; we shortcut it here. + db.conn().execute( + """ + INSERT OR IGNORE INTO cached_branches (rfc_slug, branch_name, head_sha, state, last_commit_at) + VALUES (?, 'main', ?, 'open', datetime('now')) + """, + (slug, sha), + ) + return repo_full + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +SEED_BODY = """# Open Human Model + +Open Human Model is a framework for representing humans. + +It defines consent, trait, and agency in compatible terms. +""" + + +def test_rfc_main_view_renders_against_per_rfc_repo(app_with_fake_gitea): + from fastapi.testclient import TestClient + + app, fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=1, login="ben", role="owner") + seed_active_rfc(fake, slug="open-human-model", title="Open Human Model", body=SEED_BODY) + sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner") + + r = client.get("/api/rfcs/open-human-model/main") + assert r.status_code == 200, r.text + d = r.json() + assert d["slug"] == "open-human-model" + assert "Open Human Model" in d["body"] + # main is in the branches list (cached). + assert any(b["name"] == "main" for b in d["branches"]) + + +def test_promote_to_branch_creates_branch_and_navigates(app_with_fake_gitea): + from fastapi.testclient import TestClient + from app import db + + app, fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=2, login="alice", role="contributor") + seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY) + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") + + r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={}) + assert r.status_code == 200, r.text + branch_name = r.json()["branch_name"] + assert branch_name.startswith("alice-draft-") + + # The branch is reachable as its own view. + r = client.get(f"/api/rfcs/ohm/branches/{branch_name}") + assert r.status_code == 200, r.text + view = r.json() + assert view["branch_name"] == branch_name + # The branch starts from main's body — the editor opens on it. + assert "Open Human Model" in view["body"] + # The whole-doc chat thread exists by default. + assert view["main_thread_id"] + + # The bot's create_branch action is in the audit log per §6.5. + actions = db.conn().execute( + "SELECT action_kind, on_behalf_of FROM actions WHERE action_kind = 'create_branch'" + ).fetchall() + assert any((a["action_kind"], a["on_behalf_of"]) == ("create_branch", "alice") for a in actions) + + +def test_accept_ai_change_commits_and_updates_row(app_with_fake_gitea): + from fastapi.testclient import TestClient + from app import db + + app, fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=2, login="alice", role="contributor") + seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY) + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") + + # Cut a branch the contributor owns. + r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={}) + branch = r.json()["branch_name"] + + # The whole-doc chat thread is created lazily on first branch + # view (§8.12) — GET the branch so it materializes. + view = client.get(f"/api/rfcs/ohm/branches/{branch}").json() + thread_id = view["main_thread_id"] + cur = db.conn().execute( + """ + INSERT INTO changes (rfc_slug, branch_name, thread_id, kind, state, + original, proposed, reason) + VALUES ('ohm', ?, ?, 'ai', 'pending', ?, ?, ?) + """, + ( + branch, + thread_id, + "Open Human Model is a framework for representing humans.", + "Open Human Model is a framework for representing humans in software systems.", + "tightens scope", + ), + ) + change_id = cur.lastrowid + + r = client.post( + f"/api/rfcs/ohm/branches/{branch}/changes/{change_id}/accept", + json={ + "proposed": "Open Human Model is a framework for representing humans in software systems.", + "was_edited_before_accept": False, + }, + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["commit_sha"] + + # The change row is now accepted with the commit sha bound. + row = db.conn().execute( + "SELECT state, commit_sha, acted_by, was_edited_before_accept FROM changes WHERE id = ?", + (change_id,), + ).fetchone() + assert row["state"] == "accepted" + assert row["commit_sha"] == body["commit_sha"] + assert row["acted_by"] == 2 + assert not row["was_edited_before_accept"] + + # The branch's RFC.md on Gitea now reflects the change. + owner, repo = "wiggleverse", "rfc-0001-ohm" + new_body = fake.files[(owner, repo, branch, "RFC.md")]["content"] + assert "in software systems" in new_body + + +def test_accept_with_edit_before_accept_records_flag_and_ai_original(app_with_fake_gitea): + from fastapi.testclient import TestClient + from app import db + + app, fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=2, login="alice", role="contributor") + seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY) + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") + + r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={}) + branch = r.json()["branch_name"] + view = client.get(f"/api/rfcs/ohm/branches/{branch}").json() + thread_id = view["main_thread_id"] + cur = db.conn().execute( + """ + INSERT INTO changes (rfc_slug, branch_name, thread_id, kind, state, + original, proposed, reason) + VALUES ('ohm', ?, ?, 'ai', 'pending', ?, ?, ?) + """, + (branch, thread_id, + "It defines consent, trait, and agency in compatible terms.", + "It defines consent, trait, harm, and agency in compatible terms.", + "adds harm"), + ) + change_id = cur.lastrowid + + edited = "It defines consent, trait, harm, and agency together." + r = client.post( + f"/api/rfcs/ohm/branches/{branch}/changes/{change_id}/accept", + json={"proposed": edited, "was_edited_before_accept": True}, + ) + assert r.status_code == 200, r.text + + row = db.conn().execute( + "SELECT proposed, was_edited_before_accept FROM changes WHERE id = ?", + (change_id,), + ).fetchone() + assert row["was_edited_before_accept"] == 1 + assert row["proposed"] == edited + # The commit body carries both the AI's original proposed + # text and the contributor's revision per §8.9. + body = fake.files[("wiggleverse", "rfc-0001-ohm", branch, "RFC.md")]["content"] + assert "harm" in body + # The contributor's edited text won, not the AI's. + assert "together." in body + + +def test_decline_change_persists_as_evidence_no_commit(app_with_fake_gitea): + from fastapi.testclient import TestClient + from app import db + + app, fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=2, login="alice", role="contributor") + seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY) + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") + r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={}) + branch = r.json()["branch_name"] + view = client.get(f"/api/rfcs/ohm/branches/{branch}").json() + thread_id = view["main_thread_id"] + cur = db.conn().execute( + """ + INSERT INTO changes (rfc_slug, branch_name, thread_id, kind, state, + original, proposed, reason) + VALUES ('ohm', ?, ?, 'ai', 'pending', 'x', 'y', 'why') + """, + (branch, thread_id), + ) + change_id = cur.lastrowid + + prior_sha = fake.branches[("wiggleverse", "rfc-0001-ohm")][branch]["sha"] + r = client.post(f"/api/rfcs/ohm/branches/{branch}/changes/{change_id}/decline") + assert r.status_code == 200, r.text + + # No commit, no body change. + post_sha = fake.branches[("wiggleverse", "rfc-0001-ohm")][branch]["sha"] + assert prior_sha == post_sha + + # The card stays as evidence. + row = db.conn().execute( + "SELECT state FROM changes WHERE id = ?", (change_id,) + ).fetchone() + assert row["state"] == "declined" + + +def test_manual_flush_commits_and_drops_system_message(app_with_fake_gitea): + from fastapi.testclient import TestClient + from app import db + + app, fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=2, login="alice", role="contributor") + seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY) + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") + r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={}) + branch = r.json()["branch_name"] + + new_body = SEED_BODY + "\n\nA new paragraph.\n" + r = client.post( + f"/api/rfcs/ohm/branches/{branch}/manual-flush", + json={"new_content": new_body, "paragraph_count": 1}, + ) + assert r.status_code == 200, r.text + assert r.json()["commit_sha"] + + # The branch RFC.md was updated. + body = fake.files[("wiggleverse", "rfc-0001-ohm", branch, "RFC.md")]["content"] + assert "A new paragraph" in body + + # Per §10.6: a system-author message landed in the branch chat. + rows = db.conn().execute( + """ + SELECT m.role, m.text FROM thread_messages m + JOIN threads t ON t.id = m.thread_id + WHERE t.rfc_slug = 'ohm' AND t.branch_name = ? + """, + (branch,), + ).fetchall() + assert any(r["role"] == "system" and "manual edit" in r["text"] for r in rows) + + +def test_create_flag_thread_surfaces_on_branch_view(app_with_fake_gitea): + from fastapi.testclient import TestClient + + app, fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=2, login="alice", role="contributor") + seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY) + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") + r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={}) + branch = r.json()["branch_name"] + + r = client.post( + f"/api/rfcs/ohm/branches/{branch}/threads", + json={ + "thread_kind": "flag", + "anchor_kind": "range", + "anchor_payload": {"quote": "consent"}, + "label": "needs an example", + }, + ) + assert r.status_code == 200, r.text + thread_id = r.json()["thread_id"] + + r = client.get(f"/api/rfcs/ohm/branches/{branch}") + threads = r.json()["threads"] + assert any(t["id"] == thread_id and t["thread_kind"] == "flag" for t in threads) + + +def test_visibility_flip_locks_out_non_grantees(app_with_fake_gitea): + from fastapi.testclient import TestClient + + app, fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=2, login="alice", role="contributor") + provision_user_row(user_id=3, login="bob", role="contributor") + seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY) + + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") + r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={}) + branch = r.json()["branch_name"] + + # Flip the branch private. + r = client.post( + f"/api/rfcs/ohm/branches/{branch}/visibility", + json={"read_public": False}, + ) + assert r.status_code == 200, r.text + + # Bob (a different contributor) is now blocked from reading it. + sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", role="contributor") + r = client.get(f"/api/rfcs/ohm/branches/{branch}") + assert r.status_code == 403 + + # Alice (the creator) still can. + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") + r = client.get(f"/api/rfcs/ohm/branches/{branch}") + assert r.status_code == 200 + + +def test_anonymous_can_read_main_but_not_contribute(app_with_fake_gitea): + from fastapi.testclient import TestClient + + app, fake = app_with_fake_gitea + with TestClient(app) as client: + seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY) + # No sign-in. + r = client.get("/api/rfcs/ohm/main") + assert r.status_code == 200 + r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={}) + assert r.status_code == 401 + + +def test_stale_change_refuses_silent_apply(app_with_fake_gitea): + from fastapi.testclient import TestClient + from app import db + + app, fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=2, login="alice", role="contributor") + seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY) + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") + r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={}) + branch = r.json()["branch_name"] + view = client.get(f"/api/rfcs/ohm/branches/{branch}").json() + thread_id = view["main_thread_id"] + # Stale by construction: original text not in the document. + cur = db.conn().execute( + """ + INSERT INTO changes (rfc_slug, branch_name, thread_id, kind, state, + original, proposed, reason) + VALUES ('ohm', ?, ?, 'ai', 'pending', ?, ?, ?) + """, + (branch, thread_id, "Text that does not appear", "Replacement.", "test"), + ) + change_id = cur.lastrowid + + # Refused without force. + r = client.post( + f"/api/rfcs/ohm/branches/{branch}/changes/{change_id}/accept", + json={"proposed": "Replacement.", "was_edited_before_accept": False}, + ) + assert r.status_code == 409 + # The row is marked stale per §8.11. + row = db.conn().execute( + "SELECT state, stale_since FROM changes WHERE id = ?", (change_id,) + ).fetchone() + assert row["state"] == "pending" + assert row["stale_since"] + + # Force-apply succeeds and appends. + r = client.post( + f"/api/rfcs/ohm/branches/{branch}/changes/{change_id}/accept", + json={"proposed": "Replacement.", "was_edited_before_accept": False, "force_apply_stale": True}, + ) + assert r.status_code == 200, r.text + + +# --------------------------------------------------------------------------- +# Chat streaming with a fake provider +# --------------------------------------------------------------------------- + + +class FakeProvider: + name = "claude" + display_name = "Claude" + + def __init__(self, fixed_response: str): + self._response = fixed_response + + def send(self, system, history): + return self._response + + def send_streaming(self, system, history): + # Single-chunk stream — sufficient for the orchestration test. + yield self._response + + +def test_chat_turn_materializes_change_from_change_block(app_with_fake_gitea): + from fastapi.testclient import TestClient + from app import db + + app, fake = app_with_fake_gitea + fake_response = ( + "Here is a tightening:\n\n" + "\n" + "Open Human Model is a framework for representing humans.\n" + "Open Human Model is a framework for representing humans across software systems.\n" + "scopes the framework\n" + "\n\n" + "Let me know if that fits." + ) + + with TestClient(app) as client: + provision_user_row(user_id=2, login="alice", role="contributor") + seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY) + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") + r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={}) + branch = r.json()["branch_name"] + + # Inject the fake provider — the app's `providers` dict is built + # at startup; we replace it for the test so the chat endpoint + # resolves a deterministic response. + app.state.providers["claude"] = FakeProvider(fake_response) + # The router resolved `providers` at construction time; rebuild + # the slice 2 router with the fake provider in place. + from app import api as api_routes + # Find and replace the existing branches router. Simpler: monkey + # patch the providers dict referenced by the router closure. + # The closure receives the dict by reference, so mutating it + # propagates. + # (Above mutation already does that — nothing more to do.) + + view = client.get(f"/api/rfcs/ohm/branches/{branch}").json() + thread_id = view["main_thread_id"] + + r = client.post( + f"/api/rfcs/ohm/branches/{branch}/threads/{thread_id}/chat", + json={"text": "Can you tighten the opening?", "model": "claude"}, + ) + assert r.status_code == 200, r.text + # Drain the stream so the orchestrator finishes its work. + body = r.content.decode() + assert "DONE" in body + + # A change row materialized from the block. + rows = db.conn().execute( + "SELECT kind, state, original, proposed, reason FROM changes WHERE rfc_slug = 'ohm' AND branch_name = ?", + (branch,), + ).fetchall() + ai_rows = [r for r in rows if r["kind"] == "ai"] + assert len(ai_rows) == 1 + assert ai_rows[0]["state"] == "pending" + assert "humans across software systems" in ai_rows[0]["proposed"] + assert "scopes the framework" in ai_rows[0]["reason"] + + # The assistant message persisted with the full text. + msgs = db.conn().execute( + "SELECT role, text FROM thread_messages WHERE thread_id = ? ORDER BY id", + (thread_id,), + ).fetchall() + assert msgs[-1]["role"] == "assistant" + assert "" in msgs[-1]["text"] diff --git a/docs/DEV.md b/docs/DEV.md index 3185d29..1e0c44a 100644 --- a/docs/DEV.md +++ b/docs/DEV.md @@ -49,6 +49,34 @@ chips, pending-ideas disclosure), and one end-to-end vertical: propose → idea PR opens → owner merges → super-draft appears in the catalog → super-draft view renders the body. +### Slice 2 — shipped + +The §8 active-RFC view in full. The bot wrapper grew per-RFC-repo +write operations — branch cut from main, accept-change commit with +the structured `original`/`proposed`/`reason` body and trailers, +manual-edit flush, and a `ensure_rfc_repo_seed` seam Slice 5's +graduation will eventually replace. The §4 cache now mirrors per-RFC +repos via a new `refresh_rfc_repo` path; the webhook receiver +dispatches on `repository.full_name` so per-RFC events refresh just +that repo, and the reconciler sweeps every active entry. The §18 +carryovers landed as `backend/app/providers.py` (the multi-provider +abstraction, unchanged from the prototype) and `backend/app/chat.py` +(an adapter that runs the provider's streaming interface against +`thread_messages` rows, parses `` blocks, and materializes +`changes` rows per §8.14). The §17 endpoints owned by Slice 2 — the +`branches//*` and `threads//*` families — live in +`backend/app/api_branches.py`, mounted alongside Slice 1's routes via +`api.make_router`. On the frontend, `RFCView.jsx` was rebuilt as the +§8 three-column surface; `Editor.jsx`, `ChatPanel.jsx`, +`ChangePanel.jsx`, `PromptBar.jsx`, `SelectionTooltip.jsx`, +`DiffView.jsx`, `ModelPicker.jsx`, and `modelStyles.js` were lifted +from the prototype and adapted to the canonical `threads` / +`thread_messages` / `changes` shape rather than the prototype's +global session_id. The §18 carryovers explicitly preserved: SSE +streaming with base64-encoded chunks, Tiptap + ProseMirror plugin for +the paragraph-margin gutter accent, the prompt-bar selection-quote +machinery, the model picker. + The §17 endpoints exercised so far: | Method | Path | § | @@ -64,29 +92,70 @@ The §17 endpoints exercised so far: | POST | `/api/proposals/{pr_number}/withdraw` | §9.3 | | POST | `/api/webhooks/gitea` | §4.1 | | GET | `/auth/login` / `/auth/callback` / `/auth/logout` | §18 | +| GET | `/api/models` | §18 | +| GET | `/api/rfcs/{slug}/main` | §8.1, §8.2, §17 | +| GET | `/api/rfcs/{slug}/branches/{branch}` | §8.4, §17 | +| POST | `/api/rfcs/{slug}/branches/main/promote-to-branch` | §8.14, §17 | +| POST | `/api/rfcs/{slug}/branches/{branch}/changes/{id}/accept` | §8.9, §17 | +| POST | `/api/rfcs/{slug}/branches/{branch}/changes/{id}/decline` | §8.9, §17 | +| POST | `/api/rfcs/{slug}/branches/{branch}/changes/{id}/reask` | §8.11, §17 | +| POST | `/api/rfcs/{slug}/branches/{branch}/manual-flush` | §8.11, §17 | +| POST | `/api/rfcs/{slug}/branches/{branch}/visibility` | §11.1, §17 | +| POST | `/api/rfcs/{slug}/branches/{branch}/grants` | §6.4, §17 | +| DELETE | `/api/rfcs/{slug}/branches/{branch}/grants/{login}` | §6.4 | +| GET | `/api/rfcs/{slug}/branches/{branch}/threads` | §8.12, §17 | +| POST | `/api/rfcs/{slug}/branches/{branch}/threads` | §8.12, §8.13 | +| GET | `/api/rfcs/{slug}/branches/{branch}/threads/{id}/messages` | §8.12 | +| POST | `/api/rfcs/{slug}/branches/{branch}/threads/{id}/messages` | §8.12 | +| POST | `/api/rfcs/{slug}/branches/{branch}/threads/{id}/resolve` | §8.12 | +| POST | `/api/rfcs/{slug}/branches/{branch}/threads/{id}/chat` | §18 | -### What's deferred from slice 1 +Slice 2 ships covered by `backend/tests/test_rfc_view_vertical.py` — +the FakeGitea simulator from Slice 1 grew per-RFC-repo support (PUT +contents, POST `orgs/{org}/repos`, `seed_rfc_repo`), and a new test +file walks the §8 vertical end-to-end: main-view read, promote-to- +branch, accept (with and without edit-before-accept), decline, manual +flush + system message, flag creation, visibility flip, anonymous +read-but-no-contribute, stale-change refusal, and the chat-streaming +path with a fake provider injected. -These were on the §9.1 spec but pushed to Slice 2 because they belong -with surfaces that haven't been built yet: +### What's deferred from Slice 2 -- The propose modal's **AI-suggested tags** (§9.1) — the AI surface - lands with Slice 2's chat wiring. The tag chip input works manually - in the meantime. -- The propose modal's **AI-drafted PR description** (§9.2) — same - reason. The PR description is the pitch text for now. -- The decline ceremony's **two-step composer-then-preview dialog** - (§9.3) — the single-step required-comment input is in place; the - preview-and-confirm beat is the kind of UX polish that the §19.2 - topic "pending-idea view's interaction design (remainder)" should - pick up alongside the merge-confirmation ceremony. -- The §9.3 **pre-merge chat thread on a pending-idea view** and the - migration of those threads to the super-draft on merge — depends - on Slice 2's chat infrastructure. +These were in the §8 spec but lean on infrastructure later slices +build, so they were scoped out of this slice without altering the +spec: -These are deferred in the build's working sense — surfaces exist in -the spec, but they share infrastructure that's wired in a later slice -and would otherwise have to be wired twice. +- **Super-draft body editing on the meta repo (§9.5).** The + `branches/` machinery is structurally general enough that + meta-repo edit branches fall out of it once Slice 4 wires the + super-draft view's "Start Contributing" gesture to cut against the + meta repo. The Slice 2 RFCView renders a placeholder for + super-draft entries pointing at Slice 4. +- **The §10.4 review threads on PRs.** `thread_kind='review'` is in + the schema and the threads endpoints honor it generically, but the + PR-page surface where review threads anchor to diff hunks lands + with Slice 3. +- **DiffView's full reconstruction from `changes` history.** Slice 2 + renders the editor's current HTML (which carries the + session-local tracked-change markup from the accepts that happened + in this session) into DiffView; rebuilding the full accepted-change + markup from `changes` for a returning contributor needs a render + pipeline DiffView doesn't yet own. The current behavior matches + §8.10's "session-local" framing exactly; the §19.2 "persistent + accepted-change markup" topic is the durable extension when + evidence demands it. +- **The §10.6 PR-side commit / chat reconciliation.** Manual-edit + flushes drop a system-author message into branch chat per §10.6 + in Slice 2, but the PR-side seen-cursor that uses the marker + ships with Slice 3. +- **Branch-name path conversion for slashes.** The auto-generated + branch name in Slice 2 is `-draft-` (no slash) so the + FastAPI `{branch}` path segment matches without `{branch:path}`. + Users can still rename to a slashed name, but the routes will + 404 on read; the proper fix is `{branch:path}` everywhere, which + lands cleanly when Slice 3 makes the same change to the PR routes + (PR numbers don't have this problem, but resolving the routing + shape once across both surfaces is the right hop). ## Environment notes @@ -123,31 +192,42 @@ and would otherwise have to be wired twice. ## Next slice -**Slice 2: the active-RFC view per §8.** +**Slice 3: the PR flow per §10.** -The active-RFC view inherits the three-column shape (§8.1), opens -on `main` in discuss mode by default (§8.2), supports the §8.3 -discuss-vs-contribute mode flip on non-main branches, hosts §8.4's -per-branch chat with AI participation (§18's `` protocol -parsing into `changes` rows per §8.6), the §8.8 change-card panel -with §8.9's accept / decline / edit-before-accept resolution, the -§8.10 tracked-change markup and DiffView toggle, the §8.11 manual- -edit flushes, the §8.12 range and paragraph sub-threads, the §8.13 -flag affordance, and the §8.14 discuss-mode buffer. +§8 settled the within-branch surface; §10 settles the bridge between +a branch and main. The work covers the `Open PR` affordance from +§10.1 (with the §11.3 universal-public confirmation when the branch +is private), the §10.2 AI-drafted creation modal (title + +description from the diff plus the branch chat), the §10.3 review +page (three-column, diff in the center, compressed conversation +right, per-user seen-cursor accenting new hunks and new messages), +the §10.4 `thread_kind='review'` threads anchored to diff hunks +inline in branch chat, §10.5 merge (no-fast-forward, preserving the +per-acceptance commits), §10.6 update-after-open (commits and chat +arriving on the open PR, the manual-flush system message that +already lands per Slice 2), §10.7 post-merge (`Merged` banner, +chat read-only, 90-day deletion timer starts), §10.8 withdraw, and +§10.9 conflict-replay with the resolution-branch path. The shared +seen-cursor mechanism in §15.7 (the `pr_seen` and +`branch_chat_seen` cursors are in the schema already; Slice 3 wires +the advance-on-view reconciler). -The carryover assets that belong to Slice 2 are in the prototype -under `/Users/benstull/projects/wiggleverse/rfc-app-prototype/`: +The carryovers Slice 3 inherits — none new from the prototype; the +prototype's `PRModal.jsx` had a one-shot PR-creation flow that the +spec's §10 expanded considerably. The `backend/app/bot.py` operations +Slice 3 needs are: `open_pr`, `merge_pr` (style='merge' to preserve +the per-accepted-change commit nodes per §10.5), `close_pr` (for +withdraw), and the resolution-branch replay sequence from §10.9 — +which is structurally a `cut_branch_from_main` plus a series of +`commit_accepted_change` calls plus an `open_pr`. -- `frontend/src/components/Editor.jsx`, `ChatPanel.jsx`, - `ChangePanel.jsx`, `PromptBar.jsx`, `SelectionTooltip.jsx`, - `DiffView.jsx`, `ModelPicker.jsx` — Tiptap config, the - `` parser, the selection-quote machinery, the - model-picker UX. -- `backend/providers.py`, `backend/chat.py` — the multi-provider - abstraction and the SSE-streaming chat layer. +The frontend needs a `PRView.jsx` sibling to `RFCView.jsx` that +inherits the §8.1 three-column shape but renders the diff instead +of the editor. The route is `/rfc//prs/`. -These are §18 carryovers; reuse the working code rather than -rewriting. The prototype's *data model* and *permission shape* do -not carry; this codebase's `threads`, `thread_messages`, `changes`, -`changes.thread_id`, the §6 four-role model, and the per-branch -chat thread are the canonical shape for Slice 2 to wire against. +The next build session should read `SPEC.md`, `README.md`, and +`docs/DEV.md` and pick up Slice 3 cleanly without re-briefing. The +working agreement in §19.3 continues to apply: implement the slice, +correct the spec only where running code reveals it was wrong at a +structural level, accumulate new candidate topics in §19.2, do not +extend the spec beyond what the slice requires. diff --git a/frontend/src/App.css b/frontend/src/App.css index c22ef12..bf9e3f1 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -317,3 +317,572 @@ color: #666; text-decoration: none; } .landing .secondary-link:hover { color: #1a1a1a; text-decoration: underline; } + +/* ── §8 RFC view: three-column shape ─────────────────────────────────── */ + +.main-pane { + /* Override the §9.x padded read-view; the §8 surface manages its own + internal layout and needs to fill the pane edge-to-edge. */ + padding: 0; + overflow: hidden; + display: flex; +} + +.rfc-view { + flex: 1; min-width: 0; + display: flex; flex-direction: column; + overflow: hidden; +} + +.rfc-breadcrumb { + display: flex; align-items: center; gap: 8px; + padding: 10px 16px; + border-bottom: 1px solid #e5e5e5; + background: #fafafa; + font-size: 13px; color: #555; + flex-shrink: 0; +} +.breadcrumb-label { + font-size: 11px; font-weight: 700; text-transform: uppercase; + letter-spacing: 0.05em; color: #888; +} +.breadcrumb-sep { color: #ccc; } +.breadcrumb-meta { color: #999; font-size: 12px; } +.breadcrumb-actions { margin-left: auto; display: flex; gap: 8px; align-items: center; } + +.btn-mode-toggle { + font-size: 12px; font-weight: 600; + padding: 4px 12px; border-radius: 999px; + border: 1px solid #d4d4d4; background: #fff; color: #444; + cursor: pointer; +} +.btn-mode-toggle.discuss { background: #fff; color: #444; } +.btn-mode-toggle.contribute { background: #1a1a1a; color: #fff; border-color: #1a1a1a; } + +.btn-start-contribution-header { + background: #1a1a1a; color: #fff; + border: none; border-radius: 6px; + padding: 5px 12px; font-size: 12px; font-weight: 600; + cursor: pointer; +} + +.branch-dropdown { position: relative; } +.branch-dropdown-trigger { + background: none; border: 1px solid transparent; border-radius: 5px; + padding: 3px 8px; font-weight: 600; color: #1a1a1a; font-size: 13px; + cursor: pointer; +} +.branch-dropdown-trigger:hover { border-color: #e5e5e5; } +.branch-dropdown-menu { + position: absolute; top: 100%; left: 0; margin-top: 4px; + background: #fff; border: 1px solid #e5e5e5; border-radius: 8px; + box-shadow: 0 4px 20px rgba(0,0,0,0.08); + z-index: 50; min-width: 240px; padding: 4px; +} +.branch-dropdown-item { + display: flex; align-items: center; gap: 8px; + width: 100%; padding: 6px 10px; + background: none; border: none; cursor: pointer; text-align: left; + font-size: 13px; border-radius: 5px; +} +.branch-dropdown-item:hover { background: #f5f5f5; } +.branch-dropdown-item.active { background: #f0f0ee; font-weight: 600; } +.branch-name { flex: 1; } +.branch-creator { font-size: 11px; color: #999; } +.branch-private-icon { font-size: 10px; } + +.rfc-body { flex: 1; display: flex; overflow: hidden; } + +/* ── Editor area ─────────────────────────────────────────────────────── */ + +.editor-area { + flex: 1; min-width: 0; + display: flex; flex-direction: column; + overflow: hidden; position: relative; + background: #fff; +} + +.discuss-mode-banner { + padding: 8px 16px; + background: #fffbeb; border-bottom: 1px solid #fde68a; + color: #92400e; font-size: 12px; +} +.discuss-mode-banner.muted { background: #f0f0ee; color: #666; border-color: #e5e5e5; } + +.editor-toolbar { + display: flex; align-items: center; gap: 12px; + padding: 8px 16px; + border-bottom: 1px solid #f0f0ee; + background: #fafafa; + font-size: 12px; color: #777; +} +.btn-review-toggle { + background: #fff; color: #1a1a1a; + border: 1px solid #d4d4d4; border-radius: 5px; + padding: 4px 10px; font-size: 12px; font-weight: 600; cursor: pointer; +} +.btn-review-toggle.active { background: #1a1a1a; color: #fff; border-color: #1a1a1a; } +.editor-toolbar-hint { color: #999; font-size: 11px; } + +.editor-wrapper { + flex: 1; overflow-y: auto; + padding: 32px 48px; +} +.editor-content { + max-width: 720px; margin: 0 auto; outline: none; +} +.editor-content .tiptap { + outline: none; font-size: 15px; line-height: 1.75; color: #1a1a1a; +} +.editor-content .tiptap h1 { font-size: 22px; font-weight: 700; margin: 24px 0 12px; } +.editor-content .tiptap h2 { font-size: 17px; font-weight: 600; margin: 20px 0 8px; } +.editor-content .tiptap h3 { font-size: 15px; font-weight: 600; margin: 16px 0 6px; } +.editor-content .tiptap p { margin: 0 0 12px; } +.editor-content .tiptap ul, .editor-content .tiptap ol { padding-left: 24px; } +.editor-content .tiptap code { background: #f0f0ee; padding: 1px 5px; border-radius: 3px; font-size: 13px; } + +.editor-content .tiptap .paragraph-changed { + border-left: 3px solid #f59e0b; + padding-left: 10px; + margin-left: -13px; + background: linear-gradient(to right, #fffbeb 0%, transparent 60%); + border-radius: 0 4px 4px 0; +} +.editor-content .tiptap .selection-highlight { + background: rgba(99, 102, 241, 0.15); + border-radius: 2px; + outline: 1px solid rgba(99, 102, 241, 0.3); + outline-offset: 1px; +} +.editor-content .tiptap .tracked-delete { + background: #fee2e2; color: #991b1b; text-decoration: line-through; + border-radius: 2px; padding: 1px 2px; cursor: pointer; +} +.editor-content .tiptap .tracked-insert { + background: #dcfce7; color: #166534; + border-radius: 2px; padding: 1px 2px; cursor: pointer; +} + +.readonly-bar { + border-top: 1px solid #e5e5e5; + padding: 10px 16px; text-align: center; + font-size: 13px; color: #888; background: #fafafa; +} +.readonly-bar a { color: #1a1a1a; font-weight: 600; } + +/* ── Prompt bar ──────────────────────────────────────────────────────── */ + +.prompt-bar { + border-top: 1px solid #e5e5e5; + background: #fff; + padding: 12px 48px; + flex-shrink: 0; +} +.selection-badge { + font-size: 12px; color: #5b5bd6; + margin-bottom: 8px; display: flex; align-items: center; gap: 6px; +} +.selection-icon { font-size: 10px; } +.prompt-row { + display: flex; gap: 10px; align-items: flex-end; + max-width: 720px; margin: 0 auto; +} +.prompt-input { + flex: 1; border: 1px solid #e5e5e5; border-radius: 8px; + padding: 9px 13px; font-size: 14px; font-family: inherit; + resize: none; line-height: 1.5; outline: none; + min-height: 40px; max-height: 120px; +} +.prompt-input:focus { border-color: #1a1a1a; } +.prompt-submit { + background: #1a1a1a; color: #fff; border: none; + border-radius: 8px; padding: 9px 16px; + font-size: 13px; font-weight: 600; cursor: pointer; + height: 40px; +} +.prompt-submit:disabled { background: #ccc; cursor: default; } + +/* ── Model picker ──────────────────────────────────────────────────── */ + +.model-picker { display: flex; gap: 4px; padding: 0 4px; flex-shrink: 0; } +.model-pill { + display: flex; align-items: center; gap: 5px; + padding: 4px 10px; border-radius: 20px; + font-size: 12px; font-weight: 600; + border: 1px solid #e5e5e5; background: none; color: #666; + cursor: pointer; +} +.model-pill:hover { border-color: #aaa; color: #333; } +.model-pill.active { font-weight: 700; } +.model-dot { width: 7px; height: 7px; border-radius: 50%; } + +/* ── Selection tooltip ───────────────────────────────────────────────── */ + +.selection-tooltip { + position: fixed; z-index: 100; + background: #fff; border: 1px solid #e5e5e5; + border-radius: 10px; + box-shadow: 0 4px 20px rgba(0,0,0,0.12); + padding: 8px 10px; + width: max-content; min-width: 320px; max-width: 480px; + display: flex; flex-direction: column; gap: 6px; +} +.selection-tooltip-quote { + font-size: 11px; color: #888; font-style: italic; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.selection-tooltip-tabs { display: flex; gap: 4px; } +.selection-tooltip-tab { + background: none; border: 1px solid transparent; + font-size: 12px; padding: 3px 10px; border-radius: 6px; + cursor: pointer; color: #666; +} +.selection-tooltip-tab.active { background: #1a1a1a; color: #fff; } +.selection-tooltip-input-row { display: flex; gap: 6px; align-items: center; } +.selection-tooltip-input { + flex: 1; border: 1px solid #e5e5e5; border-radius: 6px; + padding: 6px 10px; font-size: 13px; font-family: inherit; outline: none; + background: #f9f9f9; +} +.selection-tooltip-input:focus { border-color: #1a1a1a; background: #fff; } +.selection-tooltip-btn { + background: #1a1a1a; color: #fff; + border: none; border-radius: 6px; + padding: 6px 12px; font-size: 13px; font-weight: 600; cursor: pointer; +} +.selection-tooltip-btn:disabled { background: #ccc; cursor: default; } + +/* ── Right panel (chat + change panel) ───────────────────────────────── */ + +.right-panel { + width: 360px; flex-shrink: 0; + border-left: 1px solid #e5e5e5; + display: flex; flex-direction: column; + overflow: hidden; background: #fff; +} + +.chat-panel { + flex: 1; display: flex; flex-direction: column; + overflow: hidden; min-height: 0; +} +.chat-header { + padding: 10px 14px; + border-bottom: 1px solid #f0f0ee; + background: #fafafa; + display: flex; flex-direction: column; gap: 4px; +} +.chat-header-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.chat-header-title { font-size: 12px; color: #555; } +.chat-fork-link { + background: none; border: none; padding: 0; + font-size: 11px; color: #5b5bd6; cursor: pointer; +} +.chat-thread-disclosure { + font-size: 11px; color: #888; + display: flex; gap: 4px; align-items: center; +} +.chat-thread-flag-count { color: #b45309; } +.chat-filter-clear { + margin-left: auto; + background: none; border: none; cursor: pointer; + font-size: 11px; color: #5b5bd6; +} +.chat-messages { + flex: 1; overflow-y: auto; + padding: 14px; + display: flex; flex-direction: column; gap: 10px; +} +.chat-empty { + flex: 1; display: flex; flex-direction: column; + align-items: center; justify-content: center; + text-align: center; padding: 24px; +} +.chat-empty p { font-size: 13px; color: #999; line-height: 1.6; max-width: 240px; } + +.chat-message { display: flex; flex-direction: column; gap: 3px; } +.chat-message.user { align-items: flex-end; } +.chat-message.assistant { align-items: flex-start; } +.chat-message.system { align-items: stretch; } +.chat-message.flag { align-items: stretch; } +.chat-message.in-thread { padding-left: 12px; border-left: 2px solid #c4b5fd; } + +.chat-bubble { + max-width: 92%; + padding: 8px 11px; border-radius: 14px; + font-size: 13px; line-height: 1.55; + white-space: pre-wrap; word-break: break-word; +} +.chat-message.user .chat-bubble { + background: #1a1a1a; color: #fff; border-bottom-right-radius: 4px; +} +.chat-message.assistant .chat-bubble { + background: #f3f4f6; color: #1a1a1a; border-bottom-left-radius: 4px; +} +.chat-message.streaming .chat-bubble { opacity: 0.85; } +.chat-thinking { color: #999; font-style: italic; } +.chat-cursor { + display: inline-block; width: 2px; height: 12px; + background: #7c3aed; margin-left: 2px; vertical-align: middle; + animation: blink 0.75s step-end infinite; +} +@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } } + +.chat-quote { + font-size: 11px; color: #888; font-style: italic; + border-left: 2px solid #d1d5db; padding-left: 7px; margin-bottom: 3px; + max-width: 92%; align-self: flex-end; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.chat-anchor-preview { + font-size: 10px; color: #888; font-style: italic; +} + +.chat-model-label { + font-size: 11px; font-weight: 700; + display: flex; align-items: center; gap: 4px; padding-left: 2px; +} +.chat-model-dot { width: 6px; height: 6px; border-radius: 50%; } + +.chat-change-hint { + align-self: flex-start; + background: none; border: none; padding: 0; + font-size: 11px; color: #7c3aed; font-weight: 600; + cursor: pointer; +} +.chat-change-hint.discuss { color: #b45309; } +.chat-change-hint-cta { + background: none; border: none; padding: 0; + color: #5b5bd6; font-weight: 600; cursor: pointer; text-decoration: underline; +} + +.chat-system-bubble { + font-size: 11px; color: #888; font-style: italic; + border-top: 1px dashed #e5e5e5; padding-top: 8px; +} +.chat-flag-row { + display: flex; gap: 8px; + padding: 8px 10px; + background: #fff7ed; border: 1px solid #fdba74; border-radius: 8px; +} +.chat-flag-icon { color: #c2410c; font-size: 14px; } +.chat-flag-content { flex: 1; } +.chat-flag-author { font-size: 11px; font-weight: 700; color: #9a3412; } +.chat-flag-text { font-size: 13px; color: #1a1a1a; margin-top: 2px; } +.chat-flag-resolve { + margin-top: 6px; + background: none; border: 1px solid #fdba74; + border-radius: 5px; padding: 2px 8px; + font-size: 11px; color: #c2410c; cursor: pointer; +} + +/* ── Change panel ──────────────────────────────────────────────── */ + +.change-panel { + border-top: 1px solid #e5e5e5; + display: flex; flex-direction: column; + overflow: hidden; flex-shrink: 0; + max-height: 50%; +} +.change-panel-header { + padding: 12px 14px; + font-size: 13px; font-weight: 600; + border-bottom: 1px solid #f0f0ee; + display: flex; align-items: center; gap: 8px; +} +.badge { + background: #b45309; color: #fff; + font-size: 11px; padding: 2px 7px; border-radius: 10px; +} +.change-list { flex: 1; overflow-y: auto; padding: 8px; } +.change-group-label { + font-size: 10px; font-weight: 700; + text-transform: uppercase; letter-spacing: 0.06em; + color: #aaa; padding: 8px 6px 4px; +} +.change-group-label.muted { color: #ccc; } + +.change-item { + border-radius: 8px; + padding: 10px; + margin-bottom: 8px; + border: 1px solid #e5e5e5; + font-size: 13px; +} +.change-item.type-claude { border-left: 3px solid #5b5bd6; } +.change-item.type-manual { border-left: 3px solid #888; } +.change-item.state-accepted { opacity: 0.5; } +.change-item.state-declined { opacity: 0.4; } +.change-item.stale { border-color: #fbbf24; background: #fffbeb; } +.change-item.focused { + animation: change-focus-flash 1.8s ease forwards; +} +@keyframes change-focus-flash { + 0% { box-shadow: 0 0 0 3px #3b82f6; } + 70% { box-shadow: 0 0 0 3px #3b82f6; } + 100% { box-shadow: none; } +} +.change-meta { + display: flex; align-items: center; justify-content: space-between; + margin-bottom: 6px; +} +.change-author { font-weight: 600; font-size: 12px; } +.change-state-badge { + font-size: 10px; padding: 2px 7px; border-radius: 10px; + font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; +} +.change-state-badge.pending { background: #fef3c7; color: #92400e; } +.change-state-badge.accepted { background: #dcfce7; color: #166534; } +.change-state-badge.declined { background: #f1f5f9; color: #94a3b8; } +.change-state-badge.stale { background: #fef3c7; color: #92400e; } +.change-label { color: #444; margin-bottom: 6px; line-height: 1.4; } +.change-stale-banner { + font-size: 11px; color: #92400e; + background: #fef3c7; border-radius: 4px; + padding: 4px 8px; margin-bottom: 6px; +} +.change-manual-status { + font-size: 11px; color: #888; + display: flex; align-items: center; justify-content: space-between; + margin-top: 6px; +} +.btn-save-now { + background: none; border: 1px solid #d4d4d4; + border-radius: 4px; padding: 2px 8px; + font-size: 11px; font-weight: 600; cursor: pointer; +} +.change-source-link { + background: none; border: none; padding: 0; + font-size: 11px; color: #5b5bd6; cursor: pointer; + display: block; margin-top: 4px; +} +.change-actions { display: flex; gap: 6px; margin-top: 8px; flex-wrap: wrap; } +.btn-accept { + background: #166534; color: #fff; + border: none; border-radius: 5px; + padding: 5px 10px; font-size: 12px; font-weight: 600; cursor: pointer; +} +.btn-edit { + background: none; border: 1px solid #c4b5fd; color: #7c3aed; + border-radius: 5px; padding: 5px 10px; font-size: 12px; font-weight: 600; cursor: pointer; +} +.btn-decline { + background: none; border: 1px solid #e5e5e5; + border-radius: 5px; padding: 5px 10px; font-size: 12px; cursor: pointer; +} +.btn-reask { + background: none; border: 1px solid #fbbf24; color: #92400e; + border-radius: 5px; padding: 5px 10px; font-size: 12px; font-weight: 600; cursor: pointer; +} +.diff-edit-textarea { + width: 100%; font-size: 12px; font-family: inherit; + line-height: 1.5; border: 1px solid #c4b5fd; + border-radius: 4px; padding: 6px; resize: vertical; +} +.inline-diff { + font-size: 12px; line-height: 1.7; + background: #f9fafb; border-radius: 4px; + padding: 8px 10px; + white-space: pre-wrap; word-break: break-word; + border: 1px solid #e5e5e5; +} +.diff-word-add { + background: #dcfce7; color: #166534; + border-radius: 2px; padding: 1px 1px; +} +.diff-word-remove { + background: #fee2e2; color: #991b1b; + text-decoration: line-through; + border-radius: 2px; padding: 1px 1px; +} +.text-fade { color: #aaa; } +.expand-toggle { + display: block; margin-top: 5px; + background: none; border: none; padding: 0; + font-size: 11px; font-weight: 600; color: #7c3aed; + cursor: pointer; text-decoration: underline; +} + +.change-stub { + display: flex; align-items: center; gap: 6px; + font-size: 11px; color: #888; + padding: 4px 6px; border-bottom: 1px solid #f5f5f5; +} +.change-stub .stub-author { font-weight: 600; color: #555; } +.change-stub .stub-badge { + font-size: 9px; padding: 1px 5px; border-radius: 8px; +} +.change-stub .stub-badge.accepted { background: #dcfce7; color: #166534; } +.change-stub .stub-badge.declined { background: #f1f5f9; color: #94a3b8; } +.change-stub .stub-reason { flex: 1; color: #777; } +.change-stub .stub-source-link { + background: none; border: none; padding: 0; + font-size: 11px; color: #5b5bd6; cursor: pointer; +} + +.contribution-cta { + border-top: 1px solid #e5e5e5; + background: #fafafa; + padding: 16px; + text-align: center; +} +.contribution-cta-count { + font-size: 13px; font-weight: 600; color: #1a1a1a; +} +.contribution-cta-desc { + font-size: 12px; color: #666; margin: 6px 0 12px; +} +.btn-start-contribution { + background: #1a1a1a; color: #fff; + border: none; border-radius: 6px; + padding: 8px 14px; + font-size: 13px; font-weight: 600; cursor: pointer; +} + +/* ── DiffView ──────────────────────────────────────────────────── */ + +.diff-view-wrapper { + flex: 1; overflow-y: auto; + padding: 32px 48px; +} +.diff-view-empty { + font-size: 13px; color: #999; + text-align: center; padding: 24px; +} +.diff-tooltip { + background: #fff; border: 1px solid #e5e5e5; + border-radius: 8px; padding: 10px 12px; + box-shadow: 0 6px 24px rgba(0,0,0,0.12); + max-width: 320px; font-size: 12px; + z-index: 200; +} +.diff-tooltip-header { + display: flex; gap: 4px; flex-wrap: wrap; + margin-bottom: 6px; +} +.diff-tooltip-badge { + font-size: 10px; padding: 2px 7px; border-radius: 8px; + font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; +} +.diff-tooltip-badge--manual { background: #f1f5f9; color: #475569; } +.diff-tooltip-badge--edited { background: #faf5ff; color: #7c3aed; } +.diff-tooltip-prompt { + border-top: 1px solid #f0f0ee; + padding-top: 6px; + font-size: 11px; color: #444; +} +.diff-tooltip-quote { + font-style: italic; color: #888; margin-bottom: 4px; +} +.diff-tooltip-reason { + border-top: 1px solid #f0f0ee; + padding-top: 6px; margin-top: 6px; + color: #555; +} +.diff-tooltip-reason-label { + display: inline-block; font-size: 9px; font-weight: 700; + color: #888; text-transform: uppercase; letter-spacing: 0.04em; + margin-right: 6px; +} +.diff-tooltip-no-context { + font-size: 11px; color: #aaa; font-style: italic; +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 7f8e0b0..e7f62f3 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -50,7 +50,7 @@ export default function App() {
} /> - } /> + } /> setCatalogVersion(v => v + 1)} />} />
diff --git a/frontend/src/api.js b/frontend/src/api.js index 07b70d1..dc9fc4b 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -68,3 +68,189 @@ export async function withdrawProposal(prNumber) { const res = await fetch(`/api/proposals/${prNumber}/withdraw`, { method: 'POST' }) return jsonOrThrow(res) } + +// ── Slice 2: active-RFC view (§8) ───────────────────────────────────────── + +export async function listModels() { + return jsonOrThrow(await fetch('/api/models')) +} + +export async function getRFCMain(slug) { + return jsonOrThrow(await fetch(`/api/rfcs/${slug}/main`)) +} + +export async function getBranch(slug, branch) { + return jsonOrThrow(await fetch( + `/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}` + )) +} + +export async function promoteToBranch(slug, body = {}) { + const res = await fetch(`/api/rfcs/${slug}/branches/main/promote-to-branch`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + return jsonOrThrow(res) +} + +export async function acceptChange(slug, branch, changeId, { proposed, wasEdited, forceApplyStale }) { + const res = await fetch( + `/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/changes/${changeId}/accept`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + proposed, + was_edited_before_accept: !!wasEdited, + force_apply_stale: !!forceApplyStale, + }), + }, + ) + return jsonOrThrow(res) +} + +export async function declineChange(slug, branch, changeId) { + const res = await fetch( + `/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/changes/${changeId}/decline`, + { method: 'POST' }, + ) + return jsonOrThrow(res) +} + +export async function reaskChange(slug, branch, changeId) { + const res = await fetch( + `/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/changes/${changeId}/reask`, + { method: 'POST' }, + ) + return jsonOrThrow(res) +} + +export async function manualFlush(slug, branch, { newContent, paragraphCount }) { + const res = await fetch( + `/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/manual-flush`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ new_content: newContent, paragraph_count: paragraphCount }), + }, + ) + return jsonOrThrow(res) +} + +export async function setBranchVisibility(slug, branch, { readPublic, contributeMode }) { + const res = await fetch( + `/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/visibility`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + read_public: readPublic, + contribute_mode: contributeMode, + }), + }, + ) + return jsonOrThrow(res) +} + +export async function createThread(slug, branch, body) { + const res = await fetch( + `/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/threads`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }, + ) + return jsonOrThrow(res) +} + +export async function listThreads(slug, branch) { + return jsonOrThrow(await fetch( + `/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/threads`, + )) +} + +export async function getThreadMessages(slug, branch, threadId) { + return jsonOrThrow(await fetch( + `/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/threads/${threadId}/messages`, + )) +} + +export async function postThreadMessage(slug, branch, threadId, { text, quote }) { + const res = await fetch( + `/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/threads/${threadId}/messages`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text, quote }), + }, + ) + return jsonOrThrow(res) +} + +export async function resolveThread(slug, branch, threadId) { + const res = await fetch( + `/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/threads/${threadId}/resolve`, + { method: 'POST' }, + ) + return jsonOrThrow(res) +} + +// Stream a chat turn into a per-branch thread. Calls onChunk for each +// text fragment, onChanges when the trailing `changes` event arrives, +// and onDone at the terminal DONE marker. Returns the response headers +// (so the caller can pull X-Assistant-Message-Id without re-streaming). +export async function streamChatTurn(slug, branch, threadId, { text, quote, model }, { onChunk, onChanges, onDone }) { + const res = await fetch( + `/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/threads/${threadId}/chat`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text, quote, model }), + }, + ) + if (!res.ok) { + const detail = await res.text() + throw new Error(`Chat failed: ${detail || res.status}`) + } + const assistantId = res.headers.get('X-Assistant-Message-Id') + const userMsgId = res.headers.get('X-User-Message-Id') + const reader = res.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + let currentEvent = null + while (true) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + const parts = buffer.split('\n\n') + buffer = parts.pop() + for (const part of parts) { + const lines = part.split('\n') + let dataLine = null + let event = null + for (const line of lines) { + if (line.startsWith('event: ')) event = line.slice(7).trim() + if (line.startsWith('data: ')) dataLine = line.slice(6).trim() + } + if (dataLine === null) continue + if (event === 'changes') { + try { onChanges?.(JSON.parse(dataLine)) } catch {} + continue + } + if (dataLine === 'DONE') { onDone?.(); break } + try { + const text = new TextDecoder().decode( + Uint8Array.from(atob(dataLine), c => c.charCodeAt(0)) + ) + onChunk?.(text) + } catch { + // partial chunk + } + } + } + onDone?.() + return { assistantId, userMsgId } +} + diff --git a/frontend/src/components/ChangePanel.jsx b/frontend/src/components/ChangePanel.jsx new file mode 100644 index 0000000..0feb5c6 --- /dev/null +++ b/frontend/src/components/ChangePanel.jsx @@ -0,0 +1,236 @@ +// ChangePanel.jsx — the §8.8 change-card panel. +// +// Sits below the chat in contribute mode. Pending cards stack on top +// of resolved stubs. Each AI card carries accept / edit-before-accept / +// decline per §8.9; each manual card carries the live status line per +// §8.11. Stale cards surface the §8.11 warning + Re-ask path. Clicking +// a card's "↑ from this message" affordance scrolls the chat back to +// the originating message. + +import { useState, useEffect, useRef } from 'react' + +const PREVIEW_LENGTH = 220 + +function diffWords(original, proposed) { + const a = (original || '').split(/(\s+)/) + const b = (proposed || '').split(/(\s+)/) + const m = a.length, n = b.length + const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0)) + for (let i = 1; i <= m; i++) + for (let j = 1; j <= n; j++) + dp[i][j] = a[i-1] === b[j-1] + ? dp[i-1][j-1] + 1 + : Math.max(dp[i-1][j], dp[i][j-1]) + const tokens = [] + let i = m, j = n + while (i > 0 || j > 0) { + if (i > 0 && j > 0 && a[i-1] === b[j-1]) { + tokens.unshift({ text: a[i-1], type: 'same' }); i--; j-- + } else if (j > 0 && (i === 0 || dp[i][j-1] >= dp[i-1][j])) { + tokens.unshift({ text: b[j-1], type: 'add' }); j-- + } else { + tokens.unshift({ text: a[i-1], type: 'remove' }); i-- + } + } + return tokens +} + +function InlineDiff({ original, proposed }) { + const [expanded, setExpanded] = useState(false) + const tokens = diffWords(original, proposed) + const fullText = tokens.map(t => t.text).join('') + const needsTruncation = fullText.length > PREVIEW_LENGTH + let shown = tokens + if (needsTruncation && !expanded) { + let count = 0 + const cutoff = tokens.findIndex(t => { count += t.text.length; return count > PREVIEW_LENGTH }) + if (cutoff !== -1) shown = tokens.slice(0, cutoff) + } + return ( +
+ {shown.map((token, idx) => + token.type === 'same' ? {token.text} : + token.type === 'add' ? {token.text} : + {token.text} + )} + {needsTruncation && !expanded && } + {needsTruncation && ( + + )} +
+ ) +} + +export default function ChangePanel({ + changes, + onAccept, + onDecline, + onReask, + onScrollToMessage, + focusedChangeId, + manualPendingStatus, // {paragraphCount, savingIn, onSaveNow} or null +}) { + const pending = changes.filter(c => c.state === 'pending') + const resolved = changes.filter(c => c.state !== 'pending') + + return ( +
+
+ Changes + {pending.length > 0 && {pending.length}} +
+
+ {manualPendingStatus && ( + + )} + {pending.length > 0 && ( +
Pending
+ )} + {pending.map(c => ( + + ))} + {resolved.length > 0 && ( +
Resolved
+ )} + {resolved.map(c => ( + + ))} +
+
+ ) +} + +function ManualPendingCard({ paragraphCount, savingIn, onSaveNow }) { + return ( +
+
+ You · manual edit + unsaved +
+
+ {paragraphCount} paragraph{paragraphCount === 1 ? '' : 's'} edited directly +
+
+ unsaved · auto-save in {savingIn} + +
+
+ ) +} + +function ChangeItem({ change, focused, onAccept, onDecline, onReask, onScrollToMessage }) { + const [editing, setEditing] = useState(false) + const [edited, setEdited] = useState(change.proposed || '') + const itemRef = useRef(null) + const isStale = !!change.stale_since + + useEffect(() => { + if (focused && itemRef.current) { + itemRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) + } + }, [focused]) + + const handleStartEdit = () => { setEdited(change.proposed || ''); setEditing(true) } + const handleAcceptEdited = () => { + onAccept({ change, proposed: edited, wasEdited: true }) + setEditing(false) + } + const handleAcceptStraight = () => { + onAccept({ change, proposed: change.proposed, wasEdited: false }) + } + + return ( +
+
+ {change.kind === 'ai' ? 'AI' : 'You'} + + {isStale ? 'stale' : change.state} + +
+ {change.reason && ( +
{change.reason}
+ )} + {isStale && ( +
+ The original text has changed since this was proposed. +
+ )} + {change.kind === 'ai' && ( +
+ {editing ? ( +