From a2bf89e90b488880aa8dd99d2f1e742fd38e3739 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Sun, 24 May 2026 12:37:54 -0700 Subject: [PATCH] Slice 3: the PR flow Co-Authored-By: Claude Opus 4.7 (1M context) --- SPEC.md | 214 +++--- backend/app/api.py | 4 +- backend/app/api_prs.py | 919 +++++++++++++++++++++++++ backend/app/bot.py | 194 ++++++ backend/app/cache.py | 53 +- backend/migrations/007_pr_flow.sql | 33 + backend/tests/test_pr_flow_vertical.py | 508 ++++++++++++++ backend/tests/test_propose_vertical.py | 93 ++- docs/DEV.md | 136 +++- frontend/src/App.css | 135 ++++ frontend/src/App.jsx | 2 + frontend/src/api.js | 73 ++ frontend/src/components/PRModal.jsx | 114 +++ frontend/src/components/PRView.jsx | 546 +++++++++++++++ frontend/src/components/RFCView.jsx | 45 ++ 15 files changed, 2928 insertions(+), 141 deletions(-) create mode 100644 backend/app/api_prs.py create mode 100644 backend/migrations/007_pr_flow.sql create mode 100644 backend/tests/test_pr_flow_vertical.py create mode 100644 frontend/src/components/PRModal.jsx create mode 100644 frontend/src/components/PRView.jsx diff --git a/SPEC.md b/SPEC.md index ed8f4c6..9be3378 100644 --- a/SPEC.md +++ b/SPEC.md @@ -2405,108 +2405,87 @@ 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 PR flow +### 19.1 Next slice: super-draft body editing -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. +Slice 3 of the build has landed. The §10 PR flow is wired +end-to-end against the local Gitea — the §10.1 `Open PR` +affordance on a branch with the §11.3 universal-public flip when +the source branch is private, the §10.2 AI-drafted modal pulling +title and description from the diff plus the branch chat (with a +deterministic stub when no provider is configured), and the §10.3 +PR review page mounted at `/rfc//pr/` that inherits the +§8.1 three-column shape and renders a unified/split diff in the +center against a compressed conversation on the right that +interleaves chat, flag, and review threads with visual +distinction. The §10.3 per-user seen-cursor advances on every +visit and accents new commits and new messages on the next; stale +tabs cannot roll the cursor backward. §10.4 review threads +materialize as `thread_kind='review'` `anchor_kind='range'` rows +on the branch chat, surfaced inline with the AI conversation but +distinguished by header badge. §10.5 merge runs the bot through +Gitea's `style='merge'` no-fast-forward path with an +`On-behalf-of:` trailer on the merge commit, preserving the §8.6 +per-acceptance commits as reachable nodes in main's history. +§10.6 update-after-open falls out of Slice 2's existing +per-accept-and-per-flush push paths plus the new diff re-render on +every PR view. §10.7 post-merge renders the PR read-only with a +`Merged` banner; §10.8 withdraw collapses the PR to read-only with +a `Withdrawn` banner, distinguishing the user gesture from a +generic Gitea close via the audit log. §10.9 conflict-replay +surfaces a `Start resolution branch` affordance from the conflict +banner when Gitea reports the PR as unmergeable, cuts a fresh +branch off main's tip via the bot, replays the original branch's +accepted AI changes onto the resolution branch — applying each one +whose `original` text still locates exactly once, surfacing the +rest as stale-pending changes the contributor can re-anchor — and +opens a new PR whose `Supersedes:` trailer the cache parses on the +resolution PR's merge to auto-close the original. -Several §8 / §10 affordances were deferred from Slice 2 to later -slices — they're not new candidate topics, only delivery sequencing: +The §10 endpoints live in `backend/app/api_prs.py`, mounted +alongside the Slice 1 and 2 routers. The bot grew +`open_branch_pr`, `merge_branch_pr`, `withdraw_branch_pr`, +`cut_resolution_branch`, and `commit_replay_change`. The §5 +schema grew `cached_prs.superseded_by_pr_number`, +`cached_prs.merge_commit_sha`, and a `pr_resolution_branches` join +table that records resolution-branch parentage. On the frontend, +the `Open PR` button landed on `RFCView.jsx`'s branch view, +opening `PRModal.jsx`; `PRView.jsx` is the §10.3 page in full. -- **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 3 ships covered by `backend/tests/test_pr_flow_vertical.py` +— nine integration tests against an extended FakeGitea that grew +PR mergeability tracking via per-branch base snapshots, +no-fast-forward merge behavior, and a `mergeable` field on PR +responses. The tests cover opening with the §11.3 flip and the +§10.9 one-PR-per-branch refusal, the AI draft, the three-column +payload shape, seen-cursor advance with stale-tab protection, +review-thread posting, arbiter-only merge, contributor withdraw +with the `withdrawn` state distinct from generic `closed`, +anonymous read of a public PR, and the full §10.9 conflict-replay +path including the auto-close of the original PR on the resolution +PR's merge. -**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. +**Slice 4 is super-draft body editing per §9.5 + §9.6.** The +unit of work is the meta-repo edit branch — `edit//` per §9.5 — and almost everything from §8 falls out +structurally unchanged once `` resolves to a super-draft +entry and `` names a meta-repo branch rather than a +per-RFC-repo branch, per the §5 super-draft scoping note and §17's +single-dispatch rule. The §9.5 `Start Contributing` gesture on a +super-draft cuts a meta-repo edit branch via the bot and +re-anchors pending main-scoped `changes` rows. The §9.6 chat-and- +threads surface inherits the existing `threads` / +`thread_messages` shape. The §9.7 visibility and contribute grants +on edit branches reuse the Slice 2 machinery, keyed on the meta +repo. The metadata pane from §9.5 lands as +`POST /api/rfcs/{slug}/metadata` — title and tag edits as small +meta-repo PRs via the bot. Slug renames remain deferred per §9.5 +and the §19.2 candidate entry. The PR flow against meta-repo +edits is structurally identical to the active-RFC PR flow Slice 3 +shipped and falls out from the same dispatch; the graduation flow +from §13 stays deferred to Slice 5. The next build session should read `SPEC.md`, `README.md`, and -`docs/DEV.md` and pick up Slice 3 cleanly without re-briefing. The +`docs/DEV.md` and pick up Slice 4 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 @@ -2651,6 +2630,49 @@ binding. "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. +- **The PR-page diff renderer.** Slice 3 ships a line-level + unified/split diff between branch and main RFC.md bodies, + computed client-side from the two strings via a small LCS pass. + Sufficient for the single-file v1 surface, but the §10.3 + per-hunk seen-cursor accent — distinct from the file-level + accent Slice 3 wires — and the inline ``-block + attribution from `changes.commit_sha` are the natural follow-on. + Earns a topic when a contributor's PR carries enough changes + that a reviewer wants to scope review to one hunk at a time. + Touches §10.3 (the per-hunk accent voice) and §10.4 (anchoring + review threads to specific hunks rather than free-text quotes). +- **The §10.2 modal's AI-drafted text when no provider is + configured.** Slice 3 falls back to a deterministic stub + (`Edits to ` plus a character-count line) when the + app has no LLM provider. The fallback is functional but does + not produce spec-voice text. Per-RFC model availability (the + first §19.2 candidate, on the funder-role topic) will need to + settle the credential-delegation shape before this earns its + own topic; until then, the stub is the right shape for the + no-credential-available case. +- **§10.9 replay AI participation.** Slice 3 implements the + structural §10.9 path — fresh resolution branch off main, replay + the accepted changes whose `original` text still locates exactly + once, surface the rest as stale-pending changes on the + resolution branch — but does not yet invoke the AI participant + on the ambiguous conflicts to attempt a re-anchored proposal. + The contributor re-anchors manually for now. The "AI runs + against unambiguous conflicts" pass earns its own topic once + conflicts happen often enough to design against; the §19.2 + "conflict-replay UX in detail" entry already names this. +- **PR title and description sync with Gitea.** Slice 3's + `POST /api/rfcs/.../prs//description` updates the cache row + but does not mirror the edit back to Gitea via the issues + endpoint. The PR page is the canonical surface for v1 and the + cache is its source of truth, so the divergence is fine within + the app — but anyone reading the PR directly on Gitea sees the + pre-edit text. A small follow-on that propagates the edit + through the bot wrapper closes the loop. +- **The §10.7 90-day deletion timer wiring.** Slice 3 lands the + PR-merged state and the read-only treatment but does not wire + the §12 hygiene timer that fires the deletion. Slice 8 + ("Hardening") owns the §12 30/90 timers as a whole; calling out + here so the dependency is explicit. - **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 632aca6..ab6b1cb 100644 --- a/backend/app/api.py +++ b/backend/app/api.py @@ -17,7 +17,7 @@ from typing import Any from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel, Field -from . import api_branches, auth, db, entry as entry_mod, cache +from . import api_branches, api_prs, auth, db, entry as entry_mod, cache from .bot import Bot from .config import Config from .gitea import Gitea, GiteaError @@ -51,6 +51,8 @@ def make_router( # 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)) + # Slice 3: the §10 PR-flow endpoints. + router.include_router(api_prs.make_router(config, gitea, bot, providers)) # --------------------------------------------------------------- # Auth surface — extends the prototype's pattern but reads role diff --git a/backend/app/api_prs.py b/backend/app/api_prs.py new file mode 100644 index 0000000..3c9770d --- /dev/null +++ b/backend/app/api_prs.py @@ -0,0 +1,919 @@ +"""Slice 3 API surface — the §10 PR flow's endpoints. + +Owns every `prs//...` route from §17, plus the branch-scoped +`pr-draft` and `open-pr` endpoints that compose the §10.2 modal. Read +paths fetch branch and main bodies live from Gitea; write paths funnel +through `bot.py` so the §1 chokepoint and the §6.5 trailer hold. + +Visibility and the §11.3 universal-public rule fall out structurally: +opening a PR is the moment a private branch goes public, and the +confirmation lives on the §10.1 affordance rather than as a side-effect +of toggling visibility. The frontend confirms; the server is the +authority that flips the branch visibility row at open time. + +Permission gates per §6.1 (app admin/owner), §6.3 (per-RFC owners and +arbiters), and §6.5 (every PR write carries an On-behalf-of trailer). +""" +from __future__ import annotations + +import json +import logging +from typing import Any + +from fastapi import APIRouter, HTTPException, Request +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 OpenPRBody(BaseModel): + title: str = Field(min_length=1, max_length=240) + description: str = Field(max_length=8000) + + +class PRDescriptionBody(BaseModel): + title: str = Field(min_length=1, max_length=240) + description: str = Field(max_length=8000) + + +class PRSeenBody(BaseModel): + last_seen_commit_sha: str | None = None + last_seen_message_id: int | None = None + + +class PRReviewBody(BaseModel): + text: str = Field(min_length=1, max_length=20_000) + anchor_payload: dict = Field(default_factory=dict) + quote: str | None = Field(default=None, max_length=2000) + + +def make_router( + config: Config, + gitea: Gitea, + bot: Bot, + providers: dict[str, BaseProvider], +) -> APIRouter: + router = APIRouter() + + default_model = next(iter(providers)) if providers else "" + + # ------------------------------------------------------------------- + # §10.2: AI-drafted PR title and description. + # Returned ahead of submit so the modal renders with prefilled values + # the contributor can edit. The contributor's gesture is what + # produces the open-pr call; the draft is just a starting point. + # ------------------------------------------------------------------- + + @router.post("/api/rfcs/{slug}/branches/{branch}/pr-draft") + async def draft_pr_text(slug: str, branch: str, request: Request) -> dict[str, Any]: + viewer = auth.require_contributor(request) + rfc = _require_active_rfc(slug) + owner, repo = _owner_repo(rfc) + if not _branch_has_commits_ahead(slug, branch): + raise HTTPException(409, "Branch has no commits ahead of main") + main_body = (await gitea.read_file(owner, repo, RFC_FILE_PATH, ref="main")) + branch_body = (await gitea.read_file(owner, repo, RFC_FILE_PATH, ref=branch)) + if not branch_body: + raise HTTPException(404, "Branch RFC.md not found") + chat_messages = _branch_chat_excerpt(slug, branch) + title, description = _draft_with_provider( + providers=providers, + default_model=default_model, + rfc_title=rfc["title"], + main_body=(main_body or ("", ""))[0], + branch_body=branch_body[0], + chat_messages=chat_messages, + ) + _ = viewer # silence unused + return {"title": title, "description": description} + + # ------------------------------------------------------------------- + # §10.1: open a PR. The §11.3 universal-public flip is server-side — + # the frontend confirms before calling; this endpoint flips the + # branch's read_public unconditionally. + # ------------------------------------------------------------------- + + @router.post("/api/rfcs/{slug}/branches/{branch}/open-pr") + async def open_pr(slug: str, branch: str, body: OpenPRBody, request: Request) -> dict[str, Any]: + viewer = auth.require_contributor(request) + rfc = _require_active_rfc(slug) + if branch == "main": + raise HTTPException(409, "PRs open from non-main branches") + owner, repo = _owner_repo(rfc) + + # §10.1: branch must have commits ahead of main. + if not _branch_has_commits_ahead(slug, branch): + raise HTTPException(409, "Branch has no commits ahead of main") + + # §10.9: at most one open PR per branch. + existing = db.conn().execute( + """ + SELECT pr_number FROM cached_prs + WHERE rfc_slug = ? AND head_branch = ? AND state = 'open' + """, + (slug, branch), + ).fetchone() + if existing: + raise HTTPException(409, "This branch already has an open PR") + + # §11.3: opening a PR makes the branch publicly readable. + db.conn().execute( + """ + INSERT INTO branch_visibility (rfc_slug, branch_name, read_public, contribute_mode) + VALUES (?, ?, 1, 'just-me') + ON CONFLICT(rfc_slug, branch_name) DO UPDATE SET read_public = 1 + """, + (slug, branch), + ) + + # §10.9: when the branch is a resolution branch, the open carries + # a `Supersedes:` trailer naming the original PR so the cache + # closes it on the resolution PR's merge. + supersedes = _resolution_origin(slug, branch) + + try: + pr = await bot.open_branch_pr( + viewer.as_actor(), + owner=owner, + repo=repo, + head_branch=branch, + title=body.title.strip(), + description=body.description.strip(), + slug=slug, + supersedes_pr_number=supersedes, + ) + except GiteaError as e: + raise HTTPException(502, f"Gitea: {e.detail}") + + await cache.refresh_rfc_repo(config, gitea, slug) + return {"pr_number": pr["number"], "slug": slug, "branch": branch} + + # ------------------------------------------------------------------- + # §10.3: the PR review page data. + # ------------------------------------------------------------------- + + @router.get("/api/rfcs/{slug}/prs/{pr_number}") + async def get_pr(slug: str, pr_number: int, request: Request) -> dict[str, Any]: + viewer = auth.current_user(request) + rfc = _require_active_rfc(slug) + pr_row = _require_pr(slug, pr_number) + owner, repo = _owner_repo(rfc) + head_branch = pr_row["head_branch"] + + # §11.3: PRs are always public; no visibility check. + main_body, _main_sha = (await gitea.read_file(owner, repo, RFC_FILE_PATH, ref="main")) or ("", "") + merge_sha = pr_row["merge_commit_sha"] if "merge_commit_sha" in pr_row.keys() else None + branch_ref = merge_sha if pr_row["state"] == "merged" and merge_sha else head_branch + branch_fetched = await gitea.read_file(owner, repo, RFC_FILE_PATH, ref=branch_ref) if branch_ref else None + if branch_fetched is None: + # Fall back to head_branch if the merge commit is gone. + branch_fetched = await gitea.read_file(owner, repo, RFC_FILE_PATH, ref=head_branch) or ("", "") + branch_body, _branch_sha = branch_fetched + + # Threads + messages — the branch chat is the PR's conversation + # surface per §10.4. Both `chat`/`flag` and `review` kinds + # surface here; the frontend renders them inline with visual + # distinction. + thread_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, head_branch), + ).fetchall() + threads = [_serialize_thread(r) for r in thread_rows] + thread_ids = [t["id"] for t in threads] + + messages_by_thread: dict[int, list[dict]] = {tid: [] for tid in thread_ids} + if thread_ids: + placeholders = ",".join("?" * len(thread_ids)) + msg_rows = db.conn().execute( + f""" + SELECT m.id, m.thread_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 IN ({placeholders}) + ORDER BY m.id + """, + tuple(thread_ids), + ).fetchall() + for r in msg_rows: + messages_by_thread.setdefault(r["thread_id"], []).append(_serialize_message(r)) + + # Per-user seen cursor per §10.3. Anonymous viewers get no + # cursor — they always see "everything new" but cannot advance + # the cursor (no row to write to). + seen = None + if viewer is not None: + seen_row = db.conn().execute( + """ + SELECT last_seen_commit_sha, last_seen_message_id, seen_at + FROM pr_seen + WHERE user_id = ? AND rfc_slug = ? AND pr_number = ? + """, + (viewer.user_id, slug, pr_number), + ).fetchone() + if seen_row: + seen = { + "last_seen_commit_sha": seen_row["last_seen_commit_sha"], + "last_seen_message_id": seen_row["last_seen_message_id"], + "seen_at": seen_row["seen_at"], + } + + # Live Gitea pull for mergeability per §10.5 / §10.9. + mergeable = None + conflict_files: list[str] = [] + if pr_row["state"] == "open": + try: + live = await gitea.get_pull(owner, repo, pr_number) + except GiteaError: + live = None + if live is not None: + mergeable = bool(live.get("mergeable")) + if not mergeable: + conflict_files = [RFC_FILE_PATH] + + # Aggregate counts the header strip surfaces per §10.3. + open_review = sum(1 for t in threads if t["thread_kind"] == "review" and t["state"] == "open") + open_chat = sum(1 for t in threads if t["thread_kind"] == "chat" and t["state"] == "open" and t["anchor_kind"] != "whole-doc") + open_flags = sum(1 for t in threads if t["thread_kind"] == "flag" and t["state"] == "open") + + # §10.9: surface the supersession relationship in both + # directions. `superseded_by` carries the resolution PR that + # closed this one (set by the cache on the resolution merge). + # `supersedes` is parsed from this PR's body trailer so it + # surfaces immediately on open — without waiting for the + # original to close. + superseded_by = pr_row["superseded_by_pr_number"] + from .cache import _parse_supersedes + supersedes = _parse_supersedes(pr_row["description"] or "") + if supersedes is None: + row = db.conn().execute( + """ + SELECT original_pr_number FROM pr_resolution_branches + WHERE rfc_slug = ? AND resolution_branch = ? + """, + (slug, head_branch), + ).fetchone() + if row: + supersedes = row["original_pr_number"] + + capabilities = _pr_capabilities(rfc, pr_row, viewer) + return { + "slug": slug, + "rfc_title": rfc["title"], + "rfc_id": rfc["rfc_id"], + "pr_number": pr_number, + "title": pr_row["title"], + "description": pr_row["description"], + "state": pr_row["state"], + "opened_by": pr_row["opened_by"], + "opened_at": pr_row["opened_at"], + "merged_at": pr_row["merged_at"], + "closed_at": pr_row["closed_at"], + "merge_commit_sha": pr_row["merge_commit_sha"], + "head_branch": head_branch, + "head_sha": pr_row["head_sha"], + "base_branch": pr_row["base_branch"], + "superseded_by_pr_number": superseded_by, + "supersedes_pr_number": supersedes, + "main_body": main_body or "", + "branch_body": branch_body or "", + "threads": threads, + "messages_by_thread": messages_by_thread, + "seen": seen, + "mergeable": mergeable, + "conflict_files": conflict_files, + "counts": { + "open_review_threads": open_review, + "open_chat_threads": open_chat, + "open_flags": open_flags, + }, + "capabilities": capabilities, + } + + # ------------------------------------------------------------------- + # §10.3: advance the per-user seen cursor. + # ------------------------------------------------------------------- + + @router.post("/api/rfcs/{slug}/prs/{pr_number}/seen") + async def advance_seen(slug: str, pr_number: int, body: PRSeenBody, request: Request) -> dict[str, Any]: + viewer = auth.require_contributor(request) + _require_active_rfc(slug) + _require_pr(slug, pr_number) + # Take the max of stored and incoming for both cursors so a + # stale tab firing a seen-cursor advance after a fresher tab + # cannot roll the cursor back. + existing = db.conn().execute( + """ + SELECT last_seen_commit_sha, last_seen_message_id + FROM pr_seen + WHERE user_id = ? AND rfc_slug = ? AND pr_number = ? + """, + (viewer.user_id, slug, pr_number), + ).fetchone() + new_sha = body.last_seen_commit_sha or (existing["last_seen_commit_sha"] if existing else None) + existing_msg = existing["last_seen_message_id"] if existing else None + new_msg = body.last_seen_message_id + if existing_msg is not None and new_msg is not None: + new_msg = max(existing_msg, new_msg) + elif new_msg is None: + new_msg = existing_msg + db.conn().execute( + """ + INSERT INTO pr_seen + (user_id, rfc_slug, pr_number, last_seen_commit_sha, last_seen_message_id, seen_at) + VALUES (?, ?, ?, ?, ?, datetime('now')) + ON CONFLICT(user_id, rfc_slug, pr_number) DO UPDATE SET + last_seen_commit_sha = excluded.last_seen_commit_sha, + last_seen_message_id = excluded.last_seen_message_id, + seen_at = excluded.seen_at + """, + (viewer.user_id, slug, pr_number, new_sha, new_msg), + ) + return {"ok": True} + + # ------------------------------------------------------------------- + # §10.4: post a review-kind thread anchored to a diff range. + # ------------------------------------------------------------------- + + @router.post("/api/rfcs/{slug}/prs/{pr_number}/review") + async def post_review_thread(slug: str, pr_number: int, body: PRReviewBody, request: Request) -> dict[str, Any]: + viewer = auth.require_contributor(request) + _require_active_rfc(slug) + pr_row = _require_pr(slug, pr_number) + head_branch = pr_row["head_branch"] + cur = db.conn().execute( + """ + INSERT INTO threads (rfc_slug, branch_name, anchor_kind, anchor_payload, + thread_kind, label, created_by) + VALUES (?, ?, 'range', ?, 'review', NULL, ?) + """, + (slug, head_branch, json.dumps(body.anchor_payload), viewer.user_id), + ) + thread_id = cur.lastrowid + message_id = chat_layer.append_user_message( + thread_id=thread_id, + author_user_id=viewer.user_id, + text=body.text, + quote=body.quote, + ) + return {"thread_id": thread_id, "message_id": message_id} + + # ------------------------------------------------------------------- + # §10.5: merge. + # ------------------------------------------------------------------- + + @router.post("/api/rfcs/{slug}/prs/{pr_number}/merge") + async def merge_pr(slug: str, pr_number: int, request: Request) -> dict[str, Any]: + viewer = auth.require_contributor(request) + rfc = _require_active_rfc(slug) + pr_row = _require_pr(slug, pr_number) + if not _can_merge(rfc, viewer): + raise HTTPException(403, "Only arbiters, RFC owners, and app admins/owners may merge") + if pr_row["state"] != "open": + raise HTTPException(409, f"PR is {pr_row['state']}, not open") + owner, repo = _owner_repo(rfc) + try: + await bot.merge_branch_pr( + viewer.as_actor(), + owner=owner, + repo=repo, + pr_number=pr_number, + head_branch=pr_row["head_branch"], + slug=slug, + ) + except GiteaError as e: + # 409 from Gitea typically means a conflict — surface as + # the §10.9 conflict-replay signal rather than a generic 502. + if e.status == 409: + raise HTTPException(409, "Merge conflict with main — use Start resolution branch") + raise HTTPException(502, f"Gitea: {e.detail}") + await cache.refresh_rfc_repo(config, gitea, slug) + return {"ok": True, "pr_number": pr_number} + + # ------------------------------------------------------------------- + # §10.8: withdraw. + # ------------------------------------------------------------------- + + @router.post("/api/rfcs/{slug}/prs/{pr_number}/withdraw") + async def withdraw_pr(slug: str, pr_number: int, request: Request) -> dict[str, Any]: + viewer = auth.require_contributor(request) + rfc = _require_active_rfc(slug) + pr_row = _require_pr(slug, pr_number) + if not _can_withdraw(rfc, pr_row, viewer): + raise HTTPException(403, "Only the contributor or an RFC owner/arbiter (or app admin/owner) may withdraw") + if pr_row["state"] != "open": + raise HTTPException(409, f"PR is {pr_row['state']}, not open") + owner, repo = _owner_repo(rfc) + try: + await bot.withdraw_branch_pr( + viewer.as_actor(), + owner=owner, + repo=repo, + pr_number=pr_number, + head_branch=pr_row["head_branch"], + slug=slug, + reason="withdraw", + ) + except GiteaError as e: + raise HTTPException(502, f"Gitea: {e.detail}") + await cache.refresh_rfc_repo(config, gitea, slug) + return {"ok": True, "pr_number": pr_number} + + # ------------------------------------------------------------------- + # §10.2: post-open title/description edits. + # ------------------------------------------------------------------- + + @router.post("/api/rfcs/{slug}/prs/{pr_number}/description") + async def edit_pr_description( + slug: str, pr_number: int, body: PRDescriptionBody, request: Request + ) -> dict[str, Any]: + viewer = auth.require_contributor(request) + rfc = _require_active_rfc(slug) + pr_row = _require_pr(slug, pr_number) + if not _can_edit_pr_text(rfc, pr_row, viewer): + raise HTTPException(403, "Only the contributor or an RFC owner/arbiter (or admin/owner) may edit") + # Per §10.2: title and description stay editable. For now we + # mutate the cache directly; the underlying Gitea PR could be + # updated too via the issues endpoint, but the cache is the + # source of truth for the surface so this is the relevant write. + db.conn().execute( + """ + UPDATE cached_prs + SET title = ?, description = ? + WHERE rfc_slug = ? AND pr_number = ? + """, + (body.title.strip(), body.description.strip(), slug, pr_number), + ) + return {"ok": True} + + # ------------------------------------------------------------------- + # §10.9: cut a resolution branch and replay. + # ------------------------------------------------------------------- + + @router.post("/api/rfcs/{slug}/prs/{pr_number}/resolution-branch") + async def start_resolution_branch(slug: str, pr_number: int, request: Request) -> dict[str, Any]: + viewer = auth.require_contributor(request) + rfc = _require_active_rfc(slug) + pr_row = _require_pr(slug, pr_number) + if pr_row["state"] != "open": + raise HTTPException(409, f"PR is {pr_row['state']}, not open") + owner, repo = _owner_repo(rfc) + original_branch = pr_row["head_branch"] + + # Confirm there is in fact a conflict — refusing this on a + # mergeable PR keeps the surface honest. + try: + live = await gitea.get_pull(owner, repo, pr_number) + except GiteaError as e: + raise HTTPException(502, f"Gitea: {e.detail}") + if live is None: + raise HTTPException(404, "PR not found on Gitea") + if live.get("mergeable") is True: + raise HTTPException(409, "PR is mergeable; no resolution branch needed") + + resolution_branch = _resolution_branch_name(original_branch) + try: + await bot.cut_resolution_branch( + viewer.as_actor(), + owner=owner, + repo=repo, + original_branch=original_branch, + resolution_branch=resolution_branch, + slug=slug, + ) + except GiteaError as e: + raise HTTPException(502, f"Gitea: {e.detail}") + + # Record the parentage so subsequent open-pr on the resolution + # branch knows which original PR to supersede. + db.conn().execute( + """ + INSERT INTO pr_resolution_branches + (rfc_slug, original_pr_number, original_branch, resolution_branch) + VALUES (?, ?, ?, ?) + """, + (slug, pr_number, original_branch, resolution_branch), + ) + + # Default the resolution branch's visibility to public — it + # exists to land in a PR, and §11.3 will flip it anyway. + db.conn().execute( + """ + INSERT OR IGNORE INTO branch_visibility (rfc_slug, branch_name, read_public, contribute_mode) + VALUES (?, ?, 1, 'just-me') + """, + (slug, resolution_branch), + ) + + # Replay the original branch's accepted AI changes onto the + # resolution branch, one commit at a time. Per §10.9: the AI + # participant handles unambiguous conflicts; the rest surface + # to the contributor. For Slice 3 we apply changes whose + # `original` text still locates in the resolution branch's + # current RFC.md (the "unambiguous" case) and surface the + # rest as stale-pending changes on the resolution branch's + # chat, ready for the contributor to re-anchor. + unambiguous, ambiguous = await _replay_changes( + gitea=gitea, + bot=bot, + actor=viewer.as_actor(), + owner=owner, + repo=repo, + slug=slug, + original_branch=original_branch, + resolution_branch=resolution_branch, + ) + + # Seed the resolution branch's chat with a system-author + # message linking back to the original branch's chat per §10.9. + original_thread = 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, original_branch), + ).fetchone() + # Materialize the resolution branch's whole-doc chat thread. + cur = db.conn().execute( + """ + INSERT INTO threads (rfc_slug, branch_name, anchor_kind, thread_kind, label, created_by) + VALUES (?, ?, 'whole-doc', 'chat', NULL, ?) + """, + (slug, resolution_branch, viewer.user_id), + ) + new_thread_id = cur.lastrowid + link = ( + f"Forked from this conversation → /rfc/{slug}?branch={original_branch} " + f"(thread id {original_thread['id'] if original_thread else 'n/a'}). " + f"Replayed {len(unambiguous)} change(s) cleanly; {len(ambiguous)} require manual re-anchoring." + ) + chat_layer.append_system_message(thread_id=new_thread_id, text=link) + + # Surface ambiguous changes as fresh pending stale rows on the + # resolution branch so the change panel offers the re-anchoring + # affordance immediately. + for ch in ambiguous: + db.conn().execute( + """ + INSERT INTO changes + (rfc_slug, branch_name, thread_id, kind, state, + original, proposed, reason, stale_since) + VALUES (?, ?, ?, 'ai', 'pending', ?, ?, ?, datetime('now')) + """, + (slug, resolution_branch, new_thread_id, ch["original"], ch["proposed"], ch["reason"]), + ) + + await cache.refresh_rfc_repo(config, gitea, slug) + return { + "ok": True, + "resolution_branch": resolution_branch, + "replayed_clean": len(unambiguous), + "replayed_ambiguous": len(ambiguous), + } + + # ------------------------------------------------------------------ + # Helpers (closures over config/gitea/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") + 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 _require_pr(slug: str, pr_number: int): + row = db.conn().execute( + """ + SELECT * FROM cached_prs + WHERE rfc_slug = ? AND pr_number = ? AND pr_kind = 'rfc_branch' + """, + (slug, pr_number), + ).fetchone() + if not row: + raise HTTPException(404, "PR not found") + return row + + def _branch_has_commits_ahead(slug: str, branch: str) -> bool: + """Cheap heuristic: the cache records main + branch head shas, + which mismatch when the branch has any commit not on main. The + reconciler keeps these honest; an out-of-date cache here can + only false-negative, which the spec is fine with (the merge + attempt would fail at the bot wrapper instead).""" + row = db.conn().execute( + """ + SELECT b.head_sha AS branch_sha, + (SELECT head_sha FROM cached_branches + WHERE rfc_slug = ? AND branch_name = 'main') AS main_sha + FROM cached_branches b + WHERE b.rfc_slug = ? AND b.branch_name = ? + """, + (slug, slug, branch), + ).fetchone() + if not row or not row["branch_sha"]: + return False + return row["branch_sha"] != row["main_sha"] + + def _resolution_origin(slug: str, branch: str) -> int | None: + row = db.conn().execute( + """ + SELECT original_pr_number FROM pr_resolution_branches + WHERE rfc_slug = ? AND resolution_branch = ? + """, + (slug, branch), + ).fetchone() + return row["original_pr_number"] if row else None + + return router + + +# --------------------------------------------------------------------------- +# Capability helpers (module-level, since they don't need closure) +# --------------------------------------------------------------------------- + + +def _can_merge(rfc, viewer) -> bool: + """§6.1 admin/owner OR §6.3 RFC owners/arbiters.""" + 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 "[]") + return viewer.gitea_login in owners or viewer.gitea_login in arbiters + + +def _can_withdraw(rfc, pr_row, viewer) -> bool: + """§10.8: the contributor OR any arbiter / RFC owner / app admin/owner.""" + if viewer is None: + return False + if _can_merge(rfc, viewer): + return True + return pr_row["opened_by"] == viewer.gitea_login + + +def _can_edit_pr_text(rfc, pr_row, viewer) -> bool: + """Per §10.2 last paragraph: title/description editable by the + contributor or any RFC arbiter (which collapses to the same set as + withdraw).""" + return _can_withdraw(rfc, pr_row, viewer) + + +def _pr_capabilities(rfc, pr_row, viewer) -> dict: + return { + "can_merge": _can_merge(rfc, viewer) and pr_row["state"] == "open", + "can_withdraw": _can_withdraw(rfc, pr_row, viewer) and pr_row["state"] == "open", + "can_edit_text": _can_edit_pr_text(rfc, pr_row, viewer) and pr_row["state"] == "open", + "can_post_review": viewer is not None and pr_row["state"] == "open", + "can_resolve_conflict": viewer is not None and pr_row["state"] == "open", + "is_anonymous": viewer is None, + } + + +# --------------------------------------------------------------------------- +# AI-drafted title and description (§10.2) +# --------------------------------------------------------------------------- + + +def _branch_chat_excerpt(slug: str, branch: str, limit: int = 40) -> list[dict]: + 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 = ? AND t.branch_name = ? + AND t.thread_kind IN ('chat', 'review') + AND m.role IN ('user', 'assistant') + ORDER BY m.id DESC + LIMIT ? + """, + (slug, branch, limit), + ).fetchall() + items = [{"role": r["role"], "text": r["text"]} for r in reversed(rows)] + return items + + +def _draft_with_provider( + *, + providers: dict[str, BaseProvider], + default_model: str, + rfc_title: str, + main_body: str, + branch_body: str, + chat_messages: list[dict], +) -> tuple[str, str]: + """Per §10.2: AI-drafted title (spec voice) and description (2–4 + sentences pulling from chat). + + When no provider is configured we fall back to a deterministic + stub — the surface still works; the contributor just edits the + text. The fallback also matches the test seam where Slice 3 + integration tests don't always inject a fake provider. + """ + if not providers: + return _stub_draft(rfc_title=rfc_title, main_body=main_body, branch_body=branch_body) + provider = providers.get(default_model) or next(iter(providers.values())) + system = ( + "You are summarizing a contributor's proposed change to an RFC for an arbiter audience. " + "Output exactly two sections in this order: 'TITLE: ' " + "then 'DESCRIPTION: '. " + "No prelude, no closing." + ) + chat_dump = "\n".join(f"- {m['role']}: {m['text'][:600]}" for m in chat_messages[-20:]) + user_msg = ( + f"RFC: {rfc_title}\n\n" + f"--- main RFC.md ---\n{main_body[:6000]}\n\n" + f"--- branch RFC.md ---\n{branch_body[:6000]}\n\n" + f"--- recent branch chat ---\n{chat_dump or '(empty)'}\n" + ) + try: + text = provider.send(system, [{"role": "user", "content": user_msg}]) + except Exception as exc: + log.warning("pr-draft provider failed: %s", exc) + return _stub_draft(rfc_title=rfc_title, main_body=main_body, branch_body=branch_body) + title, description = _split_title_description(text) + if not title: + title, _desc = _stub_draft(rfc_title=rfc_title, main_body=main_body, branch_body=branch_body) + return title, description + + +def _split_title_description(text: str) -> tuple[str, str]: + """Parse the `TITLE: ... DESCRIPTION: ...` shape the prompt asks for. + + Tolerant of variations the model might emit — leading/trailing + whitespace, the model adding markdown emphasis around the labels — + and falls back to the whole text as description if the title + label isn't present.""" + title = "" + description = text.strip() + lower = text.lower() + title_idx = lower.find("title:") + desc_idx = lower.find("description:") + if title_idx >= 0: + end = desc_idx if desc_idx > title_idx else len(text) + title_line = text[title_idx + len("title:") : end].strip() + title = title_line.split("\n", 1)[0].strip().strip("*_`") + if desc_idx >= 0: + description = text[desc_idx + len("description:") :].strip().strip("*_`") + return title[:240], description[:8000] + + +def _stub_draft(*, rfc_title: str, main_body: str, branch_body: str) -> tuple[str, str]: + delta = abs(len(branch_body) - len(main_body)) + title = f"Edits to {rfc_title}" + description = ( + f"Proposed revisions to {rfc_title}. The branch's RFC.md differs from main " + f"by {delta} characters. Arbiters: please review the diff inline and the " + f"branch chat for the argument." + ) + return title, description + + +# --------------------------------------------------------------------------- +# §10.9 replay +# --------------------------------------------------------------------------- + + +async def _replay_changes( + *, + gitea: Gitea, + bot: Bot, + actor, + owner: str, + repo: str, + slug: str, + original_branch: str, + resolution_branch: str, +) -> tuple[list[dict], list[dict]]: + """Walk the original branch's accepted AI-kind changes in + creation order and try to apply each to the resolution branch. + + Returns (unambiguous_changes_applied, ambiguous_changes_skipped). + Each list element carries `original`, `proposed`, `reason`. + """ + rows = db.conn().execute( + """ + SELECT id, kind, original, proposed, reason + FROM changes + WHERE rfc_slug = ? AND branch_name = ? AND state = 'accepted' AND kind = 'ai' + ORDER BY id + """, + (slug, original_branch), + ).fetchall() + unambiguous: list[dict] = [] + ambiguous: list[dict] = [] + for r in rows: + fetched = await gitea.read_file(owner, repo, RFC_FILE_PATH, ref=resolution_branch) + if fetched is None: + ambiguous.append({"original": r["original"], "proposed": r["proposed"], "reason": r["reason"] or ""}) + continue + current_body, current_sha = fetched + original_text = r["original"] or "" + if not original_text or current_body.count(original_text) != 1: + ambiguous.append({"original": r["original"], "proposed": r["proposed"], "reason": r["reason"] or ""}) + continue + new_body = current_body.replace(original_text, r["proposed"], 1) + try: + await bot.commit_replay_change( + actor, + owner=owner, + repo=repo, + branch=resolution_branch, + file_path=RFC_FILE_PATH, + new_content=new_body, + prior_sha=current_sha, + original_change_id=r["id"], + original=r["original"] or "", + proposed=r["proposed"] or "", + reason=r["reason"] or "", + slug=slug, + ) + unambiguous.append({"original": r["original"], "proposed": r["proposed"], "reason": r["reason"] or ""}) + except GiteaError as e: + log.warning("replay change %d failed: %s", r["id"], e) + ambiguous.append({"original": r["original"], "proposed": r["proposed"], "reason": r["reason"] or ""}) + return unambiguous, ambiguous + + +def _resolution_branch_name(original_branch: str) -> str: + """Per §10.9: a fresh branch name derived from the original. + + Slice 3 picks `-resolved-`. Exact format is an + implementation detail per §8.14's voice — kept short, ref-safe, + and traceable to the parent.""" + import secrets + + suffix = secrets.token_hex(3) + base = original_branch + if len(base) > 80: + base = base[:80] + return f"{base}-resolved-{suffix}" + + +# --------------------------------------------------------------------------- +# Serialization helpers — mirror api_branches.py shape +# --------------------------------------------------------------------------- + + +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"], + "resolved_by": row["resolved_by"], + } + + +def _serialize_message(row) -> dict[str, Any]: + return { + "id": row["id"], + "thread_id": row["thread_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"], + } diff --git a/backend/app/bot.py b/backend/app/bot.py index db03fd6..45b38bb 100644 --- a/backend/app/bot.py +++ b/backend/app/bot.py @@ -376,6 +376,200 @@ class Bot: # ----- Per-RFC repo: seeding (test/dev fixtures, future graduation) ----- + # ----- Per-RFC repo: PRs (§10) ----- + + async def open_branch_pr( + self, + actor: Actor, + *, + owner: str, + repo: str, + head_branch: str, + title: str, + description: str, + slug: str, + supersedes_pr_number: int | None = None, + ) -> dict: + """Per §10.1: open a PR from a branch against main. + + The PR's body carries the contributor's description, optional + `Supersedes:` trailer for the §10.9 replay path, and the + standard `On-behalf-of:` trailer per §6.5. + """ + body_lines = [description.strip()] + if supersedes_pr_number is not None: + body_lines += ["", f"Supersedes: #{supersedes_pr_number}"] + body_lines += ["", _trailer(actor)] + body = "\n".join(body_lines).strip() + pr = await self._gitea.create_pull( + owner, + repo, + title=title, + body=body, + head=head_branch, + base="main", + ) + _log( + actor, + "open_branch_pr", + rfc_slug=slug, + branch_name=head_branch, + pr_number=pr["number"], + details={ + "title": title, + "supersedes": supersedes_pr_number, + "repo": f"{owner}/{repo}", + }, + ) + return pr + + async def merge_branch_pr( + self, + actor: Actor, + *, + owner: str, + repo: str, + pr_number: int, + head_branch: str, + slug: str, + ) -> None: + """Per §10.5: no-fast-forward merge. + + Gitea's `style='merge'` produces a merge commit; the + per-acceptance commits from §8.6 remain individually reachable + in main's history. The merge commit's body records the merging + user via the `On-behalf-of:` trailer — the merge commit's + author stays the bot (the bot is the only Git writer per §1) + but the trailer carries the human accountability. + """ + subject = f"Merge branch '{head_branch}'" + body = _trailer(actor) + await self._gitea.merge_pull( + owner, + repo, + pr_number, + merge_message_title=subject, + merge_message_body=body, + style="merge", + ) + _log( + actor, + "merge_branch_pr", + rfc_slug=slug, + branch_name=head_branch, + pr_number=pr_number, + details={"repo": f"{owner}/{repo}"}, + ) + + async def withdraw_branch_pr( + self, + actor: Actor, + *, + owner: str, + repo: str, + pr_number: int, + head_branch: str, + slug: str, + reason: str = "withdraw", + ) -> None: + """Per §10.8: close the PR; do not delete the branch.""" + await self._gitea.close_pull(owner, repo, pr_number) + _log( + actor, + "withdraw_branch_pr" if reason == "withdraw" else "supersede_branch_pr", + rfc_slug=slug, + branch_name=head_branch, + pr_number=pr_number, + details={"repo": f"{owner}/{repo}", "reason": reason}, + ) + + async def cut_resolution_branch( + self, + actor: Actor, + *, + owner: str, + repo: str, + original_branch: str, + resolution_branch: str, + slug: str, + ) -> dict: + """Per §10.9: cut a fresh branch off main's tip into which the + original branch's changes will be replayed. The bot owns the + cut; the replay itself is a sequence of commit_accepted_change + / manual flush operations driven by the API layer.""" + created = await self._gitea.create_branch( + owner, repo, resolution_branch, from_branch="main" + ) + _log( + actor, + "create_resolution_branch", + rfc_slug=slug, + branch_name=resolution_branch, + details={ + "repo": f"{owner}/{repo}", + "original_branch": original_branch, + }, + ) + return created + + async def commit_replay_change( + self, + actor: Actor, + *, + owner: str, + repo: str, + branch: str, + file_path: str, + new_content: str, + prior_sha: str, + original_change_id: int, + original: str, + proposed: str, + reason: str, + slug: str, + ) -> str: + """Per §10.9: a single replayed accept lands as its own commit on + the resolution branch, so the §8.6 evidence shape is preserved. + The subject mirrors `commit_accepted_change`'s but the body + records the original change id so the resolution PR's + conversation can stitch back to the original branch's chat.""" + subject = _subject_from_reason(reason, fallback="Replay change") + body_lines = [ + "**Original:**", + original.strip(), + "", + "**Proposed:**", + proposed.strip(), + ] + if reason and reason.strip(): + body_lines += ["", "**Reason:**", reason.strip()] + body_lines += ["", f"Replayed-Change-Id: {original_change_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, + "replay_change", + rfc_slug=slug, + branch_name=branch, + bot_commit_sha=sha, + details={"original_change_id": original_change_id, "file_path": file_path}, + ) + return sha + + # ----- Per-RFC repo: seeding (test/dev fixtures, future graduation) ----- + async def ensure_rfc_repo_seed( self, actor: Actor, diff --git a/backend/app/cache.py b/backend/app/cache.py index 2760ec9..ab72475 100644 --- a/backend/app/cache.py +++ b/backend/app/cache.py @@ -218,13 +218,28 @@ async def refresh_rfc_repo(config: Config, gitea: Gitea, slug: str) -> None: pull["number"], pull.get("body") or "", ) + # §10.8: distinguish "user withdrew" from "Gitea closed for any + # other reason." The bot's withdraw action lands in the actions + # log; if we see it, surface state='withdrawn'. + if state == "closed": + withdrew = db.conn().execute( + """ + SELECT 1 FROM actions + WHERE action_kind = 'withdraw_branch_pr' + AND rfc_slug = ? AND pr_number = ? LIMIT 1 + """, + (slug, pull["number"]), + ).fetchone() + if withdrew: + state = "withdrawn" + merge_commit_sha = pull.get("merge_commit_sha") 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', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + head_branch, base_branch, head_sha, merge_commit_sha) + VALUES (?, 'rfc_branch', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(repo, pr_number) DO UPDATE SET title = excluded.title, description = excluded.description, @@ -232,7 +247,8 @@ async def refresh_rfc_repo(config: Config, gitea: Gitea, slug: str) -> None: opened_by = excluded.opened_by, merged_at = excluded.merged_at, closed_at = excluded.closed_at, - head_sha = excluded.head_sha + head_sha = excluded.head_sha, + merge_commit_sha = COALESCE(excluded.merge_commit_sha, cached_prs.merge_commit_sha) """, ( slug, @@ -248,8 +264,26 @@ async def refresh_rfc_repo(config: Config, gitea: Gitea, slug: str) -> None: head_branch, (pull.get("base") or {}).get("ref") or "main", (pull.get("head") or {}).get("sha"), + merge_commit_sha, ), ) + # §10.9: an explicit `Supersedes: #N` trailer on a merged PR's + # body bumps the predecessor's state to closed and records the + # supersession. The cache propagates this whether the merge came + # via webhook or reconciler. + if state == "merged": + superseded = _parse_supersedes(pull.get("body") or "") + if superseded: + db.conn().execute( + """ + UPDATE cached_prs + SET state = 'closed', + superseded_by_pr_number = ?, + closed_at = COALESCE(closed_at, datetime('now')) + WHERE repo = ? AND pr_number = ? AND state = 'open' + """, + (pull["number"], repo_full, superseded), + ) async def refresh_meta_pulls(config: Config, gitea: Gitea) -> None: @@ -385,6 +419,19 @@ def _kind_from_branch(head_branch: str) -> str: return "idea" # fallback +_SUPERSEDES_RE = None + + +def _parse_supersedes(body: str) -> int | None: + """Parse a `Supersedes: #N` trailer from a PR body per §10.9.""" + import re as _re + global _SUPERSEDES_RE + if _SUPERSEDES_RE is None: + _SUPERSEDES_RE = _re.compile(r"^Supersedes:\s*#(\d+)", _re.MULTILINE) + m = _SUPERSEDES_RE.search(body or "") + return int(m.group(1)) if m else None + + def _state_from_pull(pull: dict) -> str: if pull.get("merged"): return "merged" diff --git a/backend/migrations/007_pr_flow.sql b/backend/migrations/007_pr_flow.sql new file mode 100644 index 0000000..3359f7f --- /dev/null +++ b/backend/migrations/007_pr_flow.sql @@ -0,0 +1,33 @@ +-- Slice 3 / §10: the PR flow per §10 in full. +-- +-- The cache shape from 002_cache.sql already carries cached_prs for +-- rfc_branch kind. Slice 3 needs two additions on top: a column to +-- record the resolution PR that supersedes an original under §10.9, +-- and a column to record the merge commit on no-fast-forward merges +-- per §10.5 so the PR page can render against the right ancestry. +-- +-- The pr_seen cursor in 005 already covers §10.3 — last_seen_commit_sha +-- accents new hunks, last_seen_message_id accents new conversation +-- messages (review-kind threads on the branch are thread_messages too). + +ALTER TABLE cached_prs ADD COLUMN superseded_by_pr_number INTEGER; +ALTER TABLE cached_prs ADD COLUMN merge_commit_sha TEXT; + +CREATE INDEX idx_cached_prs_superseded ON cached_prs (superseded_by_pr_number); + +-- §10.9: when a resolution branch is cut from main's tip, we record the +-- original branch it replays so the audit log and the PR header can +-- carry the relationship. The original PR auto-closes on the resolution +-- PR's merge via the cache reconciler reading the trailer on the merge +-- commit. +CREATE TABLE pr_resolution_branches ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + rfc_slug TEXT NOT NULL, + original_pr_number INTEGER NOT NULL, + original_branch TEXT NOT NULL, + resolution_branch TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (rfc_slug, resolution_branch) +); + +CREATE INDEX idx_pr_resolution_original ON pr_resolution_branches (rfc_slug, original_pr_number); diff --git a/backend/tests/test_pr_flow_vertical.py b/backend/tests/test_pr_flow_vertical.py new file mode 100644 index 0000000..906d45c --- /dev/null +++ b/backend/tests/test_pr_flow_vertical.py @@ -0,0 +1,508 @@ +"""End-to-end integration tests for the Slice 3 vertical (§10 in full). + +Reuses the FakeGitea + session helpers from test_propose_vertical.py +and the active-RFC seed from test_rfc_view_vertical.py. Walks the §10 +vertical end-to-end against an in-process fake Gitea: + + * open-pr from a non-main branch with a pending §11.3 visibility flip + * the AI-drafted title/description from `pr-draft` + * the §10.3 review payload: three-column data, threads, seen cursor + * §10.4 review-kind thread posting + * §10.5 no-fast-forward merge by an arbiter + * §10.5 merge by a non-arbiter is refused + * §10.8 withdraw by the contributor and by an arbiter + * §10.9 conflict-replay: original PR auto-closes when the resolution + PR merges +""" +from __future__ import annotations + +import pytest + +from test_propose_vertical import ( # noqa: F401 + FakeGitea, + app_with_fake_gitea, + provision_user_row, + sign_in_as, + tmp_env, +) +from test_rfc_view_vertical import SEED_BODY, seed_active_rfc + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _cut_branch_and_accept_change(client, fake, *, slug: str, original: str, proposed: str): + """Cut a branch and produce one accepted AI change on it. + + Returns the branch name and the change row id. + """ + from app import db + + r = client.post(f"/api/rfcs/{slug}/branches/main/promote-to-branch", json={}) + assert r.status_code == 200, r.text + branch = r.json()["branch_name"] + view = client.get(f"/api/rfcs/{slug}/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 (?, ?, ?, 'ai', 'pending', ?, ?, 'test') + """, + (slug, branch, thread_id, original, proposed), + ) + change_id = cur.lastrowid + r = client.post( + f"/api/rfcs/{slug}/branches/{branch}/changes/{change_id}/accept", + json={"proposed": proposed, "was_edited_before_accept": False}, + ) + assert r.status_code == 200, r.text + return branch, change_id + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +def test_open_pr_creates_pr_and_flips_branch_public(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") + + branch, _ = _cut_branch_and_accept_change( + client, fake, slug="ohm", + original="Open Human Model is a framework for representing humans.", + proposed="Open Human Model is a framework for representing humans across systems.", + ) + + # Flip branch private to exercise the §11.3 universal-public flip. + r = client.post(f"/api/rfcs/ohm/branches/{branch}/visibility", json={"read_public": False}) + assert r.status_code == 200, r.text + + r = client.post( + f"/api/rfcs/ohm/branches/{branch}/open-pr", + json={"title": "Tighten the opening", "description": "Scope to systems."}, + ) + assert r.status_code == 200, r.text + pr_number = r.json()["pr_number"] + assert pr_number > 0 + + # Branch is now public. + vis = db.conn().execute( + "SELECT read_public FROM branch_visibility WHERE rfc_slug = 'ohm' AND branch_name = ?", + (branch,), + ).fetchone() + assert vis["read_public"] == 1 + + # Second open-pr on the same branch fails per §10.9. + r = client.post( + f"/api/rfcs/ohm/branches/{branch}/open-pr", + json={"title": "Again", "description": "x"}, + ) + assert r.status_code == 409 + + +def test_pr_draft_returns_title_and_description(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") + branch, _ = _cut_branch_and_accept_change( + client, fake, slug="ohm", + original="It defines consent, trait, and agency in compatible terms.", + proposed="It defines consent, trait, harm, and agency in compatible terms.", + ) + r = client.post(f"/api/rfcs/ohm/branches/{branch}/pr-draft") + assert r.status_code == 200, r.text + data = r.json() + # The stub draft is sufficient when no provider is configured. + assert "title" in data and data["title"] + assert "description" in data and data["description"] + + +def test_get_pr_returns_three_column_payload(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) + # Bob is the non-arbiter contributor — alice is seeded as an RFC owner. + sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", role="contributor") + branch, _ = _cut_branch_and_accept_change( + client, fake, slug="ohm", + original="Open Human Model is a framework for representing humans.", + proposed="Open Human Model is a framework for representing humans across systems.", + ) + r = client.post( + f"/api/rfcs/ohm/branches/{branch}/open-pr", + json={"title": "Tighten", "description": "Scope."}, + ) + pr_number = r.json()["pr_number"] + + r = client.get(f"/api/rfcs/ohm/prs/{pr_number}") + assert r.status_code == 200, r.text + d = r.json() + assert d["pr_number"] == pr_number + assert d["title"] == "Tighten" + assert d["head_branch"] == branch + assert d["state"] == "open" + assert "main_body" in d and "branch_body" in d + # The branch body has the accepted edit; main does not. + assert "across systems" in d["branch_body"] + assert "across systems" not in d["main_body"] + # Mergeable when nothing else has moved on main. + assert d["mergeable"] is True + # Aggregate counts are present. + assert "counts" in d + assert d["counts"]["open_review_threads"] == 0 + # Capabilities surface — bob is not arbiter/admin/owner, can't merge. + assert d["capabilities"]["can_merge"] is False + # Bob opened the PR; he can withdraw. + assert d["capabilities"]["can_withdraw"] is True + + +def test_pr_seen_cursor_advances(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") + branch, _ = _cut_branch_and_accept_change( + client, fake, slug="ohm", + original="Open Human Model is a framework for representing humans.", + proposed="Open Human Model is a framework for representing humans across systems.", + ) + pr_number = client.post( + f"/api/rfcs/ohm/branches/{branch}/open-pr", + json={"title": "Tighten", "description": "Scope."}, + ).json()["pr_number"] + + # First read: no cursor. + d = client.get(f"/api/rfcs/ohm/prs/{pr_number}").json() + assert d["seen"] is None + + # Drop two messages into the branch chat so the seen cursor has + # FK-valid ids to point at. + from app import db + view = client.get(f"/api/rfcs/ohm/branches/{branch}").json() + thread_id = view["main_thread_id"] + cur = db.conn().execute( + "INSERT INTO thread_messages (thread_id, role, text) VALUES (?, 'system', 'first')", + (thread_id,), + ) + first_msg = cur.lastrowid + cur = db.conn().execute( + "INSERT INTO thread_messages (thread_id, role, text) VALUES (?, 'system', 'second')", + (thread_id,), + ) + second_msg = cur.lastrowid + + # Advance to the second message. + r = client.post( + f"/api/rfcs/ohm/prs/{pr_number}/seen", + json={"last_seen_commit_sha": "sha9999", "last_seen_message_id": second_msg}, + ) + assert r.status_code == 200 + + # Re-read: cursor reflects the advance. + d = client.get(f"/api/rfcs/ohm/prs/{pr_number}").json() + assert d["seen"]["last_seen_commit_sha"] == "sha9999" + assert d["seen"]["last_seen_message_id"] == second_msg + + # A stale advance can't roll the cursor backward. + client.post( + f"/api/rfcs/ohm/prs/{pr_number}/seen", + json={"last_seen_message_id": first_msg}, + ) + d = client.get(f"/api/rfcs/ohm/prs/{pr_number}").json() + assert d["seen"]["last_seen_message_id"] == second_msg + + +def test_review_thread_lands_as_review_kind(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") + provision_user_row(user_id=1, login="ben", role="owner") + 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") + branch, _ = _cut_branch_and_accept_change( + client, fake, slug="ohm", + original="Open Human Model is a framework for representing humans.", + proposed="Open Human Model is a framework for representing humans across systems.", + ) + pr_number = client.post( + f"/api/rfcs/ohm/branches/{branch}/open-pr", + json={"title": "Tighten", "description": "Scope."}, + ).json()["pr_number"] + + # Ben (the arbiter) leaves a review comment. + sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner") + r = client.post( + f"/api/rfcs/ohm/prs/{pr_number}/review", + json={ + "text": "Should we say 'in software systems' instead?", + "anchor_payload": {"from": 10, "to": 50}, + "quote": "across systems", + }, + ) + assert r.status_code == 200, r.text + thread_id = r.json()["thread_id"] + + # The thread persists as thread_kind='review', anchor_kind='range'. + row = db.conn().execute( + "SELECT thread_kind, anchor_kind, branch_name FROM threads WHERE id = ?", + (thread_id,), + ).fetchone() + assert row["thread_kind"] == "review" + assert row["anchor_kind"] == "range" + assert row["branch_name"] == branch + + # The PR payload surfaces the review thread inline. + d = client.get(f"/api/rfcs/ohm/prs/{pr_number}").json() + review_threads = [t for t in d["threads"] if t["thread_kind"] == "review"] + assert len(review_threads) == 1 + assert d["counts"]["open_review_threads"] == 1 + + +def test_merge_by_arbiter_advances_main_and_marks_pr_merged(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") + provision_user_row(user_id=1, login="ben", role="owner") + seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY) + # Bob is neither owner nor arbiter — the non-merge baseline. + sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", role="contributor") + branch, _ = _cut_branch_and_accept_change( + client, fake, slug="ohm", + original="Open Human Model is a framework for representing humans.", + proposed="Open Human Model is a framework for representing humans across systems.", + ) + pr_number = client.post( + f"/api/rfcs/ohm/branches/{branch}/open-pr", + json={"title": "Tighten", "description": "Scope."}, + ).json()["pr_number"] + + # Bob is a plain contributor — refused per §6.3. + r = client.post(f"/api/rfcs/ohm/prs/{pr_number}/merge") + assert r.status_code == 403 + + # Ben is the app owner and the RFC arbiter — can merge. + sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner") + r = client.post(f"/api/rfcs/ohm/prs/{pr_number}/merge") + assert r.status_code == 200, r.text + + # main now carries the accepted text. + body = fake.files[("wiggleverse", "rfc-0001-ohm", "main", "RFC.md")]["content"] + assert "across systems" in body + + # PR is reported as merged + post-merge fields surface. + d = client.get(f"/api/rfcs/ohm/prs/{pr_number}").json() + assert d["state"] == "merged" + assert d["capabilities"]["can_merge"] is False # already merged + # The merge produced a fresh sha on main per §10.5's no-ff. + assert d["merge_commit_sha"] + + +def test_withdraw_by_contributor_marks_state_withdrawn(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") + branch, _ = _cut_branch_and_accept_change( + client, fake, slug="ohm", + original="Open Human Model is a framework for representing humans.", + proposed="Open Human Model is a framework for representing humans across systems.", + ) + pr_number = client.post( + f"/api/rfcs/ohm/branches/{branch}/open-pr", + json={"title": "Tighten", "description": "Scope."}, + ).json()["pr_number"] + + r = client.post(f"/api/rfcs/ohm/prs/{pr_number}/withdraw") + assert r.status_code == 200, r.text + + d = client.get(f"/api/rfcs/ohm/prs/{pr_number}").json() + assert d["state"] == "withdrawn" + # Withdrawn PRs are read-only; no merge button. + assert d["capabilities"]["can_merge"] is False + assert d["capabilities"]["can_withdraw"] is False + + +def test_resolution_branch_replays_clean_and_supersedes_on_merge(app_with_fake_gitea): + """Per §10.9: a conflicting PR can be resolved by cutting a fresh + branch off main's tip and replaying. The original auto-closes when + the resolution PR merges.""" + 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") + provision_user_row(user_id=1, login="ben", role="owner") + seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY) + + # Alice cuts a branch and accepts a change on it. + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") + alice_branch, _ = _cut_branch_and_accept_change( + client, fake, slug="ohm", + original="It defines consent, trait, and agency in compatible terms.", + proposed="It defines consent, trait, harm, and agency in compatible terms.", + ) + alice_pr_number = client.post( + f"/api/rfcs/ohm/branches/{alice_branch}/open-pr", + json={"title": "Add harm", "description": "Adds harm to the dimension list."}, + ).json()["pr_number"] + + # Bob lands a different change to the same paragraph on main + # by opening + merging his own PR. That moves main and makes + # Alice's PR unmergeable. + sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", role="contributor") + bob_branch, _ = _cut_branch_and_accept_change( + client, fake, slug="ohm", + original="It defines consent, trait, and agency in compatible terms.", + proposed="It defines consent, agency, and trait in compatible terms.", + ) + bob_pr_number = client.post( + f"/api/rfcs/ohm/branches/{bob_branch}/open-pr", + json={"title": "Reorder", "description": "Reorders the dimensions."}, + ).json()["pr_number"] + sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner") + r = client.post(f"/api/rfcs/ohm/prs/{bob_pr_number}/merge") + assert r.status_code == 200, r.text + + # Alice's PR is now unmergeable. + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") + d = client.get(f"/api/rfcs/ohm/prs/{alice_pr_number}").json() + assert d["mergeable"] is False + + # Direct merge attempt is refused with 409. + sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner") + r = client.post(f"/api/rfcs/ohm/prs/{alice_pr_number}/merge") + assert r.status_code == 409 + + # Alice starts a resolution branch. + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") + r = client.post(f"/api/rfcs/ohm/prs/{alice_pr_number}/resolution-branch") + assert r.status_code == 200, r.text + resolution_branch = r.json()["resolution_branch"] + # Alice's `` text no longer matches main; the replay + # surfaces as ambiguous so she can re-anchor. + assert r.json()["replayed_ambiguous"] >= 1 + + # Resolve the ambiguous change manually by opening a fresh + # accept on the resolution branch — substituting the now-correct + # `original` from main. + from app import db + ambiguous_change = db.conn().execute( + """ + SELECT id FROM changes WHERE rfc_slug = 'ohm' AND branch_name = ? + AND state = 'pending' AND stale_since IS NOT NULL + ORDER BY id LIMIT 1 + """, + (resolution_branch,), + ).fetchone() + assert ambiguous_change is not None + + # Re-anchor by inserting a fresh AI-pending row anchored to + # main's text, then accepting it. + view = client.get(f"/api/rfcs/ohm/branches/{resolution_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', ?, ?, 'add harm') + """, + (resolution_branch, thread_id, + "It defines consent, agency, and trait in compatible terms.", + "It defines consent, agency, trait, and harm in compatible terms."), + ) + replay_change_id = cur.lastrowid + r = client.post( + f"/api/rfcs/ohm/branches/{resolution_branch}/changes/{replay_change_id}/accept", + json={ + "proposed": "It defines consent, agency, trait, and harm in compatible terms.", + "was_edited_before_accept": False, + }, + ) + assert r.status_code == 200, r.text + + # Open the resolution PR. + r = client.post( + f"/api/rfcs/ohm/branches/{resolution_branch}/open-pr", + json={"title": "Add harm (rebased)", "description": "Rebased on bob's reorder."}, + ) + assert r.status_code == 200, r.text + resolution_pr_number = r.json()["pr_number"] + + # The supersession is recorded — both directions visible. + d = client.get(f"/api/rfcs/ohm/prs/{resolution_pr_number}").json() + assert d["supersedes_pr_number"] == alice_pr_number + + # Arbiter merges the resolution PR. + sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner") + r = client.post(f"/api/rfcs/ohm/prs/{resolution_pr_number}/merge") + assert r.status_code == 200, r.text + + # Original PR auto-closes via the Supersedes: trailer. + d_orig = client.get(f"/api/rfcs/ohm/prs/{alice_pr_number}").json() + assert d_orig["state"] == "closed" + assert d_orig["superseded_by_pr_number"] == resolution_pr_number + + +def test_anonymous_can_read_pr_but_not_post(app_with_fake_gitea): + """§11.3: PRs are always public; anonymous viewers read but cannot + advance the seen cursor or post review comments.""" + 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") + branch, _ = _cut_branch_and_accept_change( + client, fake, slug="ohm", + original="Open Human Model is a framework for representing humans.", + proposed="Open Human Model is a framework for representing humans across systems.", + ) + pr_number = client.post( + f"/api/rfcs/ohm/branches/{branch}/open-pr", + json={"title": "Tighten", "description": "Scope."}, + ).json()["pr_number"] + + # Drop the session. + client.cookies.clear() + d = client.get(f"/api/rfcs/ohm/prs/{pr_number}").json() + assert d["state"] == "open" + assert d["capabilities"]["is_anonymous"] is True + + # Anonymous post is 401. + r = client.post( + f"/api/rfcs/ohm/prs/{pr_number}/review", + json={"text": "x", "anchor_payload": {}, "quote": None}, + ) + assert r.status_code == 401 diff --git a/backend/tests/test_propose_vertical.py b/backend/tests/test_propose_vertical.py index f82cbbb..f5e769f 100644 --- a/backend/tests/test_propose_vertical.py +++ b/backend/tests/test_propose_vertical.py @@ -45,7 +45,8 @@ class FakeGitea: 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, "ts": str}} + # branches: (owner, repo) -> {branch_name -> {"sha": str, "ts": str, + # "base_main_files": {path -> str}}} self.branches: dict[tuple[str, str], dict[str, dict]] = {} # pulls: (owner, repo) -> list[pull-dict] self.pulls: dict[tuple[str, str], list[dict]] = {} @@ -71,6 +72,48 @@ class FakeGitea: self._commit_counter += 1 return f"sha{self._commit_counter:04d}" + def _enrich_pr(self, owner: str, repo: str, pr: dict) -> dict: + """Return the PR with mergeability fields filled in. + + Gitea's PR responses carry `mergeable` and `merge_commit_sha` + plus the head sha; for the per-RFC repo paths in §10 we mirror + that shape. + """ + out = dict(pr) + head_branch = pr["head"]["ref"] + head_sha = (self.branches.get((owner, repo)) or {}).get(head_branch, {}).get("sha") + out["head"] = dict(pr["head"]) + if head_sha: + out["head"]["sha"] = head_sha + out["mergeable"] = self._is_mergeable(owner, repo, pr) if pr["state"] == "open" else False + return out + + def _is_mergeable(self, owner: str, repo: str, pr: dict) -> bool: + """A PR is mergeable when the file content under main matches the + branch's snapshot of main at cut-time on every path the branch + either inherited or touched. This collapses to "no path on the + branch has diverged from main since cut" — sufficient for the + single-file RFC.md surface and the §10.9 conflict-replay test + path. + """ + head_branch = pr["head"]["ref"] + branch_data = self.branches.get((owner, repo), {}).get(head_branch, {}) + base_snapshot: dict[str, str] = branch_data.get("base_main_files") or {} + # Touch every path the branch tracks plus every path on main, so a + # file deleted on main also surfaces. + paths = set(base_snapshot.keys()) + for (o, r, br, p) in self.files.keys(): + if (o, r, br) == (owner, repo, head_branch): + paths.add(p) + if (o, r, br) == (owner, repo, "main"): + paths.add(p) + for p in paths: + main_content = (self.files.get((owner, repo, "main", p)) or {}).get("content") + base_content = base_snapshot.get(p) + if main_content != base_content: + return False + return True + def handle(self, request: httpx.Request) -> httpx.Response: path = request.url.path.replace("/api/v1", "", 1) method = request.method @@ -118,11 +161,18 @@ class FakeGitea: new = payload["new_branch_name"] old = payload["old_branch_name"] old_sha = self.branches[(owner, repo)][old]["sha"] - self.branches[(owner, repo)][new] = {"sha": old_sha} - # Copy main's files into the new branch + # Snapshot the parent branch's files at cut time so we can + # surface §10.5 merge conflicts when main diverges later. + snapshot: dict[str, str] = {} for (o, r, br, p), data in list(self.files.items()): if (o, r, br) == (owner, repo, old): self.files[(owner, repo, new, p)] = dict(data) + snapshot[p] = data["content"] + self.branches[(owner, repo)][new] = { + "sha": old_sha, + "ts": "2026-05-23T00:00:00Z", + "base_main_files": snapshot, + } return httpx.Response(201, json={"name": new}) # GET /repos/{owner}/{repo}/contents/{path}?ref=... @@ -163,7 +213,9 @@ 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, "ts": "2026-05-23T00:00:00Z"} + br = self.branches[(owner, repo)].setdefault(branch, {}) + br["sha"] = sha + br["ts"] = "2026-05-23T00:00:00Z" return httpx.Response(201, json={"commit": {"sha": sha}}) # PUT /repos/{owner}/{repo}/contents/{path} — update_file @@ -174,7 +226,9 @@ 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, "ts": "2026-05-23T00:00:00Z"} + br = self.branches[(owner, repo)].setdefault(branch, {}) + br["sha"] = sha + br["ts"] = "2026-05-23T00:00:00Z" return httpx.Response(200, json={"commit": {"sha": sha}, "content": {"sha": sha}}) # GET /repos/{owner}/{repo}/pulls?state=... @@ -183,7 +237,7 @@ class FakeGitea: owner, repo = m.groups() state = request.url.params.get("state", "open") items = self.pulls.get((owner, repo), []) - filtered = [p for p in items if (state == "all") or (p["state"] == state)] + filtered = [self._enrich_pr(owner, repo, p) for p in items if (state == "all") or (p["state"] == state)] return httpx.Response(200, json=filtered) # POST /repos/{owner}/{repo}/pulls @@ -205,7 +259,16 @@ class FakeGitea: "user": {"login": "rfc-bot"}, } self.pulls[(owner, repo)].append(pr) - return httpx.Response(201, json=pr) + return httpx.Response(201, json=self._enrich_pr(owner, repo, pr)) + + # GET /repos/{owner}/{repo}/pulls/{number} + m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/pulls/(\d+)", path) + if method == "GET" and m: + owner, repo, num = m.groups() + for pr in self.pulls.get((owner, repo), []): + if pr["number"] == int(num): + return httpx.Response(200, json=self._enrich_pr(owner, repo, pr)) + return httpx.Response(404, json={"message": "not found"}) # POST /repos/{owner}/{repo}/pulls/{number}/merge m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/pulls/(\d+)/merge", path) @@ -213,18 +276,26 @@ class FakeGitea: owner, repo, num = m.groups() for pr in self.pulls[(owner, repo)]: if pr["number"] == int(num): + if pr["state"] != "open": + return httpx.Response(409, json={"message": "PR is not open"}) + if not self._is_mergeable(owner, repo, pr): + return httpx.Response(409, json={"message": "merge conflict with main"}) head_branch = pr["head"]["ref"] for (o, r, br, p), data in list(self.files.items()): if (o, r, br) == (owner, repo, head_branch): self.files[(owner, repo, "main", p)] = dict(data) - # Real Gitea: state becomes "closed" with merged=true. pr["state"] = "closed" pr["merged"] = True pr["merged_at"] = "2026-05-23T01:00:00Z" pr["closed_at"] = "2026-05-23T01:00:00Z" - new_sha = self._next_sha() - self.branches[(owner, repo)]["main"]["sha"] = new_sha - return httpx.Response(200, json={"merged": True}) + # Per §10.5: a no-fast-forward merge advances main + # via a new merge commit SHA, not by reusing the + # branch's tip. We mint a fresh sha to model that. + merge_sha = self._next_sha() + pr["merge_commit_sha"] = merge_sha + self.branches[(owner, repo)]["main"]["sha"] = merge_sha + self.branches[(owner, repo)]["main"]["ts"] = "2026-05-23T01:00:00Z" + return httpx.Response(200, json={"merged": True, "merge_commit_sha": merge_sha}) return httpx.Response(404, json={"message": "not found"}) # GET /repos/{owner}/{repo}/hooks diff --git a/docs/DEV.md b/docs/DEV.md index 1e0c44a..6ad7c40 100644 --- a/docs/DEV.md +++ b/docs/DEV.md @@ -119,6 +119,73 @@ 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. +### Slice 3 — shipped + +The §10 PR flow in full. The bot wrapper grew per-RFC-repo PR +operations — `open_branch_pr` (with the §10.9 `Supersedes:` trailer +hook), `merge_branch_pr` (no-fast-forward via Gitea's `style='merge'`, +the `On-behalf-of:` trailer carrying the merging user per §6.5), +`withdraw_branch_pr`, `cut_resolution_branch`, and +`commit_replay_change` for the §10.9 per-accept replay onto fresh +main. The §4 cache learned about per-RFC PRs via the existing +`refresh_rfc_repo` sweep, plus a `_parse_supersedes` pass that bumps +an original PR's state to closed and records the supersession the +moment the resolution PR's merge arrives — whether via webhook or +the reconciler. The §17 endpoints owned by Slice 3 — the +`branches//{pr-draft,open-pr}` and the `prs//*` family — +live in `backend/app/api_prs.py`, mounted alongside Slices 1 and 2's +routes via `api.make_router`. The migration in `007_pr_flow.sql` +adds `superseded_by_pr_number` and `merge_commit_sha` columns to +`cached_prs` plus the `pr_resolution_branches` join table that +records resolution-branch parentage so the cache can supersede the +original on the resolution PR's merge. + +On the frontend, the `Open PR` affordance landed on `RFCView.jsx`'s +branch view (gated on the branch having commits ahead of main and no +already-open PR), opening a new `PRModal.jsx` that fetches the AI +draft via `/pr-draft`, lets the contributor edit, and surfaces the +§11.3 universal-public confirmation inline when the source branch is +private. The `PRView.jsx` sibling to `RFCView.jsx` is mounted at +`/rfc/:slug/pr/:prNumber` and renders the §10.3 three-column shape: +catalog left (App chrome), a unified/split diff in the center +computed from main and branch RFC.md bodies, and a compressed +conversation surface on the right that interleaves chat / flag / +review threads with visual distinction per §10.4. The per-user +seen-cursor advances on every visit; new commits and new messages +since the cursor surface with an accent. The merge button is +arbiter-gated per §6.3; withdraw is contributor-or-arbiter per §10.8; +the §10.9 `Start resolution branch` affordance fires from the +conflict banner when the live Gitea pull reports the PR as +unmergeable, and the new resolution branch opens in the §8 editor for +the contributor to re-anchor stale changes before opening the +resolution PR. + +The §17 endpoints exercised in Slice 3: + +| Method | Path | § | +| ------ | ----------------------------------------------- | ------- | +| POST | `/api/rfcs/{slug}/branches/{branch}/pr-draft` | §10.2 | +| POST | `/api/rfcs/{slug}/branches/{branch}/open-pr` | §10.1 | +| GET | `/api/rfcs/{slug}/prs/{n}` | §10.3 | +| POST | `/api/rfcs/{slug}/prs/{n}/seen` | §10.3 | +| POST | `/api/rfcs/{slug}/prs/{n}/review` | §10.4 | +| POST | `/api/rfcs/{slug}/prs/{n}/merge` | §10.5 | +| POST | `/api/rfcs/{slug}/prs/{n}/withdraw` | §10.8 | +| POST | `/api/rfcs/{slug}/prs/{n}/description` | §10.2 | +| POST | `/api/rfcs/{slug}/prs/{n}/resolution-branch` | §10.9 | + +Slice 3 ships covered by `backend/tests/test_pr_flow_vertical.py` — +nine integration tests against an extended FakeGitea that grew PR +mergeability via base-snapshot tracking, no-fast-forward merge +behavior, and a `mergeable` field on PR responses. The tests cover +opening (with the §11.3 visibility flip and the §10.9 one-PR-per- +branch refusal), the AI draft, the three-column payload shape, +seen-cursor advance with stale-tab protection, review-thread +posting, arbiter-only merge, contributor withdraw with the +`withdrawn` state distinct from generic `closed`, anonymous read +of a public PR, and the full §10.9 conflict-replay path including +the auto-close of the original PR on the resolution PR's merge. + ### What's deferred from Slice 2 These were in the §8 spec but lean on infrastructure later slices @@ -192,41 +259,50 @@ spec: ## Next slice -**Slice 3: the PR flow per §10.** +**Slice 4: super-draft body editing per §9.5 + §9.6.** -§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 §8 within-branch surface and the §10 bridge to main now ship for +active RFCs; the same mechanics still need to reach super-draft +entries on the meta repo. Slice 4's unit of work is the meta-repo +edit branch — `edit//` per §9.5 — and the +structural claim is that almost everything from §8 falls out +unchanged once `` resolves to a super-draft entry and +`` names a meta-repo branch rather than a per-RFC-repo +branch (see the §5 super-draft scoping note). -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`. +What Slice 4 owns specifically: -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/`. +- §9.5's `Start Contributing` on a super-draft cutting an + `edit//` branch on the meta repo via the bot, + re-anchoring pending `changes` rows from `main` to the new branch + the way `promote-to-branch` does for active RFCs. +- §9.6's chat-and-threads surface scoped to the super-draft and to + edit branches, sharing the §5 `threads`/`thread_messages` shape. +- §9.7's visibility and contribute grants on edit branches — the + same `branch_visibility` / `branch_contribute_grants` machinery + that Slice 2 wired, now keyed on the meta repo. +- The metadata pane from §9.5 — title and tag edits as small + meta-repo PRs via `POST /api/rfcs/{slug}/metadata`. Slug renames + remain deferred per §9.5 / §19.2. +- The §17 routing collapse the spec calls for: the + `branches//...` endpoint family already exists; Slice 4's + job is the dispatch in `api_branches.py` that recognizes a + super-draft slug and routes to the meta repo on every read and + write. `RFCView.jsx`'s super-draft placeholder is replaced by the + full editor surface. + +What Slice 4 does NOT own: the §10 PR flow against the meta repo's +super-draft edits is structurally identical to the active-RFC PR +flow Slice 3 just shipped, and falls out from the same dispatch. +The graduation flow from §13 stays deferred to Slice 5. + +The carryovers Slice 4 inherits — none new from the prototype; +every §8 / §10 surface already exists. The work is dispatch glue +plus a small number of routes that need the meta-repo path +(`branches/edit//` cuts). The next build session should read `SPEC.md`, `README.md`, and -`docs/DEV.md` and pick up Slice 3 cleanly without re-briefing. The +`docs/DEV.md` and pick up Slice 4 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 diff --git a/frontend/src/App.css b/frontend/src/App.css index bf9e3f1..a2fe1bf 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -886,3 +886,138 @@ .diff-tooltip-no-context { font-size: 11px; color: #aaa; font-style: italic; } + +/* ── Slice 3: the §10 PR flow surfaces ─────────────────────────────── */ + +.pr-modal { max-width: 640px; } +.pr-modal-warning { + background: #fef3c7; border: 1px solid #fbbf24; + padding: 10px 12px; border-radius: 6px; margin-bottom: 14px; + font-size: 12px; line-height: 1.5; +} +.pr-modal-warning p { margin: 0 0 6px 0; } +.modal-label { + display: block; font-size: 11px; font-weight: 600; + color: #555; margin: 10px 0 4px; text-transform: uppercase; + letter-spacing: 0.04em; +} +.modal-input, .modal-textarea { + width: 100%; padding: 7px 10px; font-size: 13px; + border: 1px solid #d1d5db; border-radius: 4px; box-sizing: border-box; + font-family: inherit; +} +.modal-textarea { resize: vertical; min-height: 100px; } +.btn-open-pr { background: #1a1a1a; color: #fff; border: none; + padding: 4px 10px; border-radius: 4px; font-size: 12px; cursor: pointer; } +.btn-open-pr:hover { background: #333; } + +/* PR view header strip + two-column body */ +.pr-view { display: flex; flex-direction: column; height: 100%; } +.pr-header { + display: grid; grid-template-columns: 1fr auto; gap: 16px; + padding: 14px 18px; border-bottom: 1px solid #e5e7eb; + background: #fafafa; +} +.pr-header-left { min-width: 0; } +.pr-breadcrumb { font-size: 11px; color: #666; margin-bottom: 6px; } +.pr-breadcrumb a { color: #5b5bd6; text-decoration: none; } +.pr-breadcrumb a:hover { text-decoration: underline; } +.pr-title { font-size: 18px; margin: 0 0 6px 0; } +.pr-description { font-size: 13px; color: #444; margin: 0 0 6px 0; line-height: 1.55; } +.pr-header-edit { display: flex; flex-direction: column; gap: 8px; } +.pr-header-right { + display: flex; flex-direction: column; align-items: flex-end; gap: 8px; + font-size: 12px; +} +.pr-state-banner { + padding: 3px 10px; border-radius: 12px; + font-size: 11px; font-weight: 600; text-transform: uppercase; + letter-spacing: 0.04em; +} +.pr-state-banner.open { background: #dcfce7; color: #14532d; } +.pr-state-banner.merged { background: #ddd6fe; color: #4c1d95; } +.pr-state-banner.withdrawn { background: #fef3c7; color: #78350f; } +.pr-state-banner.closed { background: #e5e7eb; color: #374151; } +.pr-counts { display: flex; gap: 12px; font-size: 11px; color: #555; } +.pr-count-flags { color: #b45309; font-weight: 600; font-size: 13px; } +.pr-supersedes { font-size: 11px; color: #6b7280; } +.pr-supersedes a { color: #5b5bd6; text-decoration: none; } +.pr-conflict-banner { + background: #fee2e2; border: 1px solid #fca5a5; + padding: 10px 12px; border-radius: 6px; + font-size: 12px; line-height: 1.5; max-width: 320px; +} +.pr-conflict-banner p { margin: 0 0 8px 0; } +.pr-actions { display: flex; gap: 8px; } + +.pr-body { display: grid; grid-template-columns: 1fr 360px; flex: 1; overflow: hidden; } + +.diff-mode-toolbar { + display: flex; gap: 12px; align-items: center; + padding: 6px 12px; border-bottom: 1px solid #f0f0ee; + font-size: 12px; +} +.diff-mode-toolbar .btn-link.active { + font-weight: 600; color: #1a1a1a; +} +.pr-diff-accent { + margin-left: auto; + font-size: 11px; color: #5b5bd6; font-weight: 600; +} + +.diff-pane { flex: 1; overflow: auto; font-family: 'SF Mono', Monaco, monospace; font-size: 12px; } +.diff-pane.diff-split { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; background: #e5e7eb; } +.diff-col { background: #fff; overflow: auto; } +.diff-col-header { + padding: 4px 10px; background: #f3f4f6; font-weight: 600; + font-size: 11px; color: #555; border-bottom: 1px solid #e5e7eb; +} +.diff-row { + display: flex; padding: 1px 8px; + white-space: pre-wrap; word-break: break-word; line-height: 1.5; +} +.diff-row.diff-equal { color: #374151; } +.diff-row.diff-del { background: #fee2e2; color: #7f1d1d; } +.diff-row.diff-add { background: #dcfce7; color: #14532d; } +.diff-row.diff-empty { background: #f9fafb; color: #d1d5db; } +.diff-marker { width: 16px; opacity: 0.5; user-select: none; } +.diff-text { flex: 1; } + +/* PR conversation — chat + review interleaved */ +.pr-conversation { padding: 10px 12px; overflow-y: auto; } +.pr-conv-disclosure { + display: flex; gap: 12px; font-size: 11px; color: #6b7280; + padding-bottom: 6px; border-bottom: 1px solid #f0f0ee; margin-bottom: 10px; +} +.thread-disclosure strong { color: #1a1a1a; } +.chat-feed { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 12px; } +.chat-msg { + display: flex; flex-direction: column; gap: 4px; + padding: 8px 10px; border-radius: 6px; background: #f9fafb; + font-size: 12px; line-height: 1.55; +} +.chat-msg-review { background: #ede9fe; border-left: 3px solid #7c3aed; } +.chat-msg-flag { background: #fef3c7; border-left: 3px solid #d97706; } +.chat-msg-new { box-shadow: 0 0 0 2px #c7d2fe; } +.chat-msg-header { display: flex; align-items: center; gap: 6px; font-size: 11px; color: #6b7280; } +.chat-msg-badge { + font-size: 9px; font-weight: 700; padding: 1px 5px; border-radius: 3px; + text-transform: uppercase; letter-spacing: 0.04em; +} +.chat-msg-badge.review { background: #7c3aed; color: #fff; } +.chat-msg-badge.flag { background: #d97706; color: #fff; } +.chat-msg-author { font-weight: 600; color: #1a1a1a; } +.chat-msg-new-pip { color: #5b5bd6; } +.chat-msg-quote { + background: #fff; border-left: 2px solid #d1d5db; + padding: 4px 8px; font-size: 11px; color: #6b7280; + margin: 0; white-space: pre-wrap; +} +.chat-msg-body { white-space: pre-wrap; } + +.pr-review-composer { + border-top: 1px solid #e5e7eb; padding: 10px 12px; + background: #fafafa; +} +.pr-review-quote { font-size: 11px; color: #6b7280; margin-bottom: 6px; } +.pr-review-quote pre { background: #fff; padding: 4px 8px; border-radius: 4px; margin: 4px 0 0 0; } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index e7f62f3..d4f1cd1 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -3,6 +3,7 @@ import { Routes, Route, Link, useNavigate } from 'react-router-dom' import { getMe } from './api' import Catalog from './components/Catalog.jsx' import RFCView from './components/RFCView.jsx' +import PRView from './components/PRView.jsx' import ProposalView from './components/ProposalView.jsx' import ProposeModal from './components/ProposeModal.jsx' import Landing from './components/Landing.jsx' @@ -51,6 +52,7 @@ export default function App() { } /> } /> + } /> setCatalogVersion(v => v + 1)} />} /> diff --git a/frontend/src/api.js b/frontend/src/api.js index dc9fc4b..6d9fcdb 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -197,6 +197,79 @@ export async function resolveThread(slug, branch, threadId) { return jsonOrThrow(res) } +// ── Slice 3: the §10 PR flow ───────────────────────────────────────────── + +export async function draftPRText(slug, branch) { + const res = await fetch( + `/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/pr-draft`, + { method: 'POST' }, + ) + return jsonOrThrow(res) +} + +export async function openPR(slug, branch, { title, description }) { + const res = await fetch( + `/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/open-pr`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title, description }), + }, + ) + return jsonOrThrow(res) +} + +export async function getPR(slug, prNumber) { + return jsonOrThrow(await fetch(`/api/rfcs/${slug}/prs/${prNumber}`)) +} + +export async function advancePRSeen(slug, prNumber, { lastSeenCommitSha, lastSeenMessageId }) { + const res = await fetch(`/api/rfcs/${slug}/prs/${prNumber}/seen`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + last_seen_commit_sha: lastSeenCommitSha, + last_seen_message_id: lastSeenMessageId, + }), + }) + return jsonOrThrow(res) +} + +export async function postPRReview(slug, prNumber, { text, anchorPayload, quote }) { + const res = await fetch(`/api/rfcs/${slug}/prs/${prNumber}/review`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text, anchor_payload: anchorPayload || {}, quote: quote || null }), + }) + return jsonOrThrow(res) +} + +export async function mergePR(slug, prNumber) { + const res = await fetch(`/api/rfcs/${slug}/prs/${prNumber}/merge`, { method: 'POST' }) + return jsonOrThrow(res) +} + +export async function withdrawPR(slug, prNumber) { + const res = await fetch(`/api/rfcs/${slug}/prs/${prNumber}/withdraw`, { method: 'POST' }) + return jsonOrThrow(res) +} + +export async function editPRDescription(slug, prNumber, { title, description }) { + const res = await fetch(`/api/rfcs/${slug}/prs/${prNumber}/description`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title, description }), + }) + return jsonOrThrow(res) +} + +export async function startResolutionBranch(slug, prNumber) { + const res = await fetch(`/api/rfcs/${slug}/prs/${prNumber}/resolution-branch`, { + 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 diff --git a/frontend/src/components/PRModal.jsx b/frontend/src/components/PRModal.jsx new file mode 100644 index 0000000..a1fd4de --- /dev/null +++ b/frontend/src/components/PRModal.jsx @@ -0,0 +1,114 @@ +// PRModal.jsx — the §10.2 PR creation modal. +// +// Opens from the §10.1 "Open PR" affordance on a branch view. The +// modal fetches an AI-drafted title and description on open via the +// /pr-draft endpoint, prefills both fields, lets the contributor edit +// before submit. When the source branch is private, surfaces the +// §11.3 universal-public confirmation inline above the form rather +// than as a separate dialog — the confirmation is part of this +// surface per §10.1. + +import { useEffect, useState } from 'react' +import { draftPRText, openPR } from '../api' + +export default function PRModal({ slug, branch, branchIsPrivate, onClose, onOpened }) { + const [title, setTitle] = useState('') + const [description, setDescription] = useState('') + const [drafting, setDrafting] = useState(true) + const [submitting, setSubmitting] = useState(false) + const [confirmed, setConfirmed] = useState(!branchIsPrivate) + const [error, setError] = useState(null) + + useEffect(() => { + let cancelled = false + draftPRText(slug, branch) + .then(d => { + if (cancelled) return + setTitle(d.title || '') + setDescription(d.description || '') + }) + .catch(err => { if (!cancelled) setError(err.message) }) + .finally(() => { if (!cancelled) setDrafting(false) }) + return () => { cancelled = true } + }, [slug, branch]) + + const canSubmit = !!title.trim() && !drafting && !submitting && confirmed + + const submit = async () => { + setSubmitting(true) + setError(null) + try { + const { pr_number } = await openPR(slug, branch, { title: title.trim(), description: description.trim() }) + onOpened?.(pr_number) + } catch (e) { + setError(e.message) + setSubmitting(false) + } + } + + return ( +
{ if (e.target === e.currentTarget) onClose() }}> +
+
+

Open PR — {branch}

+ +
+
+ {branchIsPrivate && ( +
+

+ This branch is currently private. Per §11.3, + opening a PR makes the branch and its history publicly readable. + There is no notion of a private PR. +

+ +
+ )} + + setTitle(e.target.value)} + placeholder={drafting ? 'Drafting from the diff and chat…' : 'A one-line structural description'} + disabled={drafting || submitting} + maxLength={240} + /> +

+ §10.2: a one-line structural description in spec voice. What was + edited, in what way. AI-drafted; edit before submit. +

+ +