Slice 3: the PR flow

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-05-24 12:37:54 -07:00
parent 33d9d7a482
commit a2bf89e90b
15 changed files with 2928 additions and 141 deletions
+118 -96
View File
@@ -2405,108 +2405,87 @@ surface. With Topic 13 folded in, the structural surface is
complete. What follows is no longer "topics that block specifying complete. What follows is no longer "topics that block specifying
v1" but "topics to address during or shortly after the v1 build." 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 Slice 3 of the build has landed. The §10 PR flow is wired
end-to-end against the local Gitea: the three-column shape (§8.1) end-to-end against the local Gitea the §10.1 `Open PR`
inherits the §7 catalog on the left, hosts a Tiptap editor in the affordance on a branch with the §11.3 universal-public flip when
center with a breadcrumb dropdown listing main + open branches + the source branch is private, the §10.2 AI-drafted modal pulling
open PRs, and surfaces per-branch chat plus the change-card panel title and description from the diff plus the branch chat (with a
on the right. Opening an active RFC lands on `main` in discuss mode deterministic stub when no provider is configured), and the §10.3
per §8.2; `Start Contributing` on main calls the §17 PR review page mounted at `/rfc/<slug>/pr/<n>` that inherits the
promote-to-branch endpoint to cut a new branch via the bot and §8.1 three-column shape and renders a unified/split diff in the
re-anchors any pending `main`-scoped `changes` rows to it (§8.14). center against a compressed conversation on the right that
On a non-main branch the §8.3 discuss-vs-contribute toggle flips interleaves chat, flag, and review threads with visual
the editor between read-only and edit-enabled. The §18 carryovers distinction. The §10.3 per-user seen-cursor advances on every
landed in `backend/app/providers.py` and `backend/app/chat.py` and visit and accents new commits and new messages on the next; stale
on the frontend as `Editor.jsx`, `ChatPanel.jsx`, `ChangePanel.jsx`, tabs cannot roll the cursor backward. §10.4 review threads
`PromptBar.jsx`, `SelectionTooltip.jsx`, `DiffView.jsx`, and materialize as `thread_kind='review'` `anchor_kind='range'` rows
`ModelPicker.jsx`. AI chat parses `<change>` blocks per §18 into on the branch chat, surfaced inline with the AI conversation but
`changes` rows with `state='pending'` per §8.14; accept runs the distinguished by header badge. §10.5 merge runs the bot through
bot's per-accepted-change commit (§8.6) with the structured body Gitea's `style='merge'` no-fast-forward path with an
and `Change-Id`, `Source-Message-Id`, and `On-behalf-of:` trailers; `On-behalf-of:` trailer on the merge commit, preserving the §8.6
decline persists the row as evidence per §8.9; edit-before-accept per-acceptance commits as reachable nodes in main's history.
preserves the AI's original text under an `AI proposed:` section §10.6 update-after-open falls out of Slice 2's existing
of the commit body per §8.9. Manual edits flush as one commit per per-accept-and-per-flush push paths plus the new diff re-render on
window with a system-author chat message landing per §10.6. The every PR view. §10.7 post-merge renders the PR read-only with a
§8.10 tracked-change markup is session-local in the editor; DiffView `Merged` banner; §10.8 withdraw collapses the PR to read-only with
is the read-only render of the same accepted changes. The §8.11 a `Withdrawn` banner, distinguishing the user gesture from a
stale-change machinery sets `changes.stale_since` when a manual generic Gitea close via the audit log. §10.9 conflict-replay
edit changes the document such that a pending AI proposal's surfaces a `Start resolution branch` affordance from the conflict
`original` no longer locates; the re-ask and force-apply paths are banner when Gitea reports the PR as unmergeable, cuts a fresh
wired. The §8.12 range threads (via the selection tooltip) and the branch off main's tip via the bot, replays the original branch's
§8.13 flag threads (via the selection tooltip's flag tab) materialize accepted AI changes onto the resolution branch — applying each one
as `threads` rows scoped to the branch; the chat feed renders them whose `original` text still locates exactly once, surfacing the
inline with the whole-doc default thread. The §11.1 visibility and rest as stale-pending changes the contributor can re-anchor — and
§6.4 contribute grants are wired with the branch-creator / opens a new PR whose `Supersedes:` trailer the cache parses on the
arbiter / admin set per §11.1, §11.2, §6.3. The §4 cache grew a resolution PR's merge to auto-close the original.
`refresh_rfc_repo` path that the webhook dispatches per
`repository.full_name` and the reconciler sweeps for every active
entry. The vertical is covered by `backend/tests/test_rfc_view_vertical.py`
— eleven integration tests against an extended FakeGitea that
supports per-RFC repos.
Several §8 / §10 affordances were deferred from Slice 2 to later The §10 endpoints live in `backend/app/api_prs.py`, mounted
slices — they're not new candidate topics, only delivery sequencing: 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).** Slice 3 ships covered by `backend/tests/test_pr_flow_vertical.py`
The `branches/<branch>` machinery is structurally general enough — nine integration tests against an extended FakeGitea that grew
that meta-repo edit branches fall out of it once Slice 4 wires PR mergeability tracking via per-branch base snapshots,
the super-draft view's "Start Contributing" to cut against the no-fast-forward merge behavior, and a `mergeable` field on PR
meta repo. The Slice 2 RFCView renders a placeholder for responses. The tests cover opening with the §11.3 flip and the
super-draft entries pointing at Slice 4. §10.9 one-PR-per-branch refusal, the AI draft, the three-column
- **PR-anchored review threads (§10.4).** `thread_kind='review'` is payload shape, seen-cursor advance with stale-tab protection,
in the §5 schema and the threads endpoints honor it generically, review-thread posting, arbiter-only merge, contributor withdraw
but the PR-page surface that anchors review threads to diff with the `withdrawn` state distinct from generic `closed`,
hunks lands with Slice 3. anonymous read of a public PR, and the full §10.9 conflict-replay
- **DiffView's full reconstruction from `changes` history.** Slice 2 path including the auto-close of the original PR on the resolution
's DiffView renders the editor's current HTML, which carries the PR's merge.
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 is the PR flow per §10 in full.** Open a PR via the **Slice 4 is super-draft body editing per §9.5 + §9.6.** The
§10.1 affordance on a branch (with the §11.3 universal-public unit of work is the meta-repo edit branch — `edit/<slug>/<auto-
confirmation when the branch is private); the §10.2 AI-drafted name>` per §9.5 — and almost everything from §8 falls out
creation modal pulls title and description from the diff plus the structurally unchanged once `<slug>` resolves to a super-draft
branch chat. The §10.3 PR review page inherits the §8.1 entry and `<branch>` names a meta-repo branch rather than a
three-column shape — catalog left, diff (unified or split) in the per-RFC-repo branch, per the §5 super-draft scoping note and §17's
center, the compressed conversation plus the inline review-comment single-dispatch rule. The §9.5 `Start Contributing` gesture on a
surface (§10.4) on the right. The §10.3 per-user seen-cursor super-draft cuts a meta-repo edit branch via the bot and
mechanism accents new hunks and new conversation messages on the re-anchors pending main-scoped `changes` rows. The §9.6 chat-and-
next visit. The §10.4 review comments materialize as threads surface inherits the existing `threads` /
`thread_kind='review'`, `anchor_kind='range'` threads anchored to `thread_messages` shape. The §9.7 visibility and contribute grants
the post-PR document state, surfaced inline with the AI chat on edit branches reuse the Slice 2 machinery, keyed on the meta
visually distinguished. §10.5 merge writes a no-fast-forward merge repo. The metadata pane from §9.5 lands as
commit preserving the per-accepted-change commit nodes from §8.6 `POST /api/rfcs/{slug}/metadata` — title and tag edits as small
as individual reachable commits in main's history. §10.6 update- meta-repo PRs via the bot. Slug renames remain deferred per §9.5
after-open re-renders the diff as new commits arrive (which they and the §19.2 candidate entry. The PR flow against meta-repo
already do — Slice 2's manual-flush and accept-change paths both edits is structurally identical to the active-RFC PR flow Slice 3
push immediately). §10.7 post-merge renders the PR read-only with shipped and falls out from the same dispatch; the graduation flow
a `Merged` banner and starts §12's 90-day deletion timer. §10.8 from §13 stays deferred to Slice 5.
withdraw closes the PR with the same read-only treatment. §10.9
conflict-replay cuts a fresh resolution branch off main's tip,
replays the source branch's diff (running the AI participant
against unambiguous conflicts and surfacing the rest to the
contributor), and opens a new PR with the original auto-closing on
merge.
The carryover assets Slice 3 inherits: none new from the prototype
beyond what Slice 2 already lifted. The prototype's `PRModal.jsx`
was a one-shot submission flow; §10's PR creation modal is its
descendant but the spec broadened the surface considerably. The
seen-cursor advances are pure schema work — `pr_seen` and
`branch_chat_seen` are in the §5 schema; Slice 3 wires the
advance-on-view reconciler from §15.7.
The next build session should read `SPEC.md`, `README.md`, and 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 working agreement in §19.3 continues to apply: implement the
slice, correct the spec only where running code reveals it was slice, correct the spec only where running code reveals it was
wrong at a structural level, accumulate new candidate topics in 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 "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 treatment is the natural follow-on. Surfaced by §8.14's data path
going through Slice 2 for the first time. 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 `<change>`-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 <RFC title>` 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/<n>/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. - **Body full-text search.** When the time comes.
Topic 13 (notifications) is settled and folded into §5 (the Topic 13 (notifications) is settled and folded into §5 (the
+3 -1
View File
@@ -17,7 +17,7 @@ from typing import Any
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field 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 .bot import Bot
from .config import Config from .config import Config
from .gitea import Gitea, GiteaError 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. # Slice 2: the §8 active-RFC view's endpoints live in api_branches.
# Mounting them on the same router keeps the §17 layout flat. # Mounting them on the same router keeps the §17 layout flat.
router.include_router(api_branches.make_router(config, gitea, bot, providers)) 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 # Auth surface — extends the prototype's pattern but reads role
+919
View File
@@ -0,0 +1,919 @@
"""Slice 3 API surface — the §10 PR flow's endpoints.
Owns every `prs/<pr_number>/...` 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 (24
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: <one line, structural, spec-voice>' "
"then 'DESCRIPTION: <two to four sentences pulling what was argued and what shifted>'. "
"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 `<original>-resolved-<hex>`. 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"],
}
+194
View File
@@ -376,6 +376,200 @@ class Bot:
# ----- Per-RFC repo: seeding (test/dev fixtures, future graduation) ----- # ----- 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( async def ensure_rfc_repo_seed(
self, self,
actor: Actor, actor: Actor,
+50 -3
View File
@@ -218,13 +218,28 @@ async def refresh_rfc_repo(config: Config, gitea: Gitea, slug: str) -> None:
pull["number"], pull["number"],
pull.get("body") or "", 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( db.conn().execute(
""" """
INSERT INTO cached_prs INSERT INTO cached_prs
(rfc_slug, pr_kind, repo, pr_number, title, description, state, (rfc_slug, pr_kind, repo, pr_number, title, description, state,
opened_by, opened_at, merged_at, closed_at, opened_by, opened_at, merged_at, closed_at,
head_branch, base_branch, head_sha) head_branch, base_branch, head_sha, merge_commit_sha)
VALUES (?, 'rfc_branch', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, 'rfc_branch', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(repo, pr_number) DO UPDATE SET ON CONFLICT(repo, pr_number) DO UPDATE SET
title = excluded.title, title = excluded.title,
description = excluded.description, description = excluded.description,
@@ -232,7 +247,8 @@ async def refresh_rfc_repo(config: Config, gitea: Gitea, slug: str) -> None:
opened_by = excluded.opened_by, opened_by = excluded.opened_by,
merged_at = excluded.merged_at, merged_at = excluded.merged_at,
closed_at = excluded.closed_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, slug,
@@ -248,8 +264,26 @@ async def refresh_rfc_repo(config: Config, gitea: Gitea, slug: str) -> None:
head_branch, head_branch,
(pull.get("base") or {}).get("ref") or "main", (pull.get("base") or {}).get("ref") or "main",
(pull.get("head") or {}).get("sha"), (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: 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 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: def _state_from_pull(pull: dict) -> str:
if pull.get("merged"): if pull.get("merged"):
return "merged" return "merged"
+33
View File
@@ -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);
+508
View File
@@ -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 `<original>` 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
+82 -11
View File
@@ -45,7 +45,8 @@ class FakeGitea:
def __init__(self): def __init__(self):
# files: (owner, repo, branch, path) -> {"content": str, "sha": str} # files: (owner, repo, branch, path) -> {"content": str, "sha": str}
self.files: dict[tuple[str, str, str, str], dict] = {} 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]] = {} self.branches: dict[tuple[str, str], dict[str, dict]] = {}
# pulls: (owner, repo) -> list[pull-dict] # pulls: (owner, repo) -> list[pull-dict]
self.pulls: dict[tuple[str, str], list[dict]] = {} self.pulls: dict[tuple[str, str], list[dict]] = {}
@@ -71,6 +72,48 @@ class FakeGitea:
self._commit_counter += 1 self._commit_counter += 1
return f"sha{self._commit_counter:04d}" 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: def handle(self, request: httpx.Request) -> httpx.Response:
path = request.url.path.replace("/api/v1", "", 1) path = request.url.path.replace("/api/v1", "", 1)
method = request.method method = request.method
@@ -118,11 +161,18 @@ class FakeGitea:
new = payload["new_branch_name"] new = payload["new_branch_name"]
old = payload["old_branch_name"] old = payload["old_branch_name"]
old_sha = self.branches[(owner, repo)][old]["sha"] old_sha = self.branches[(owner, repo)][old]["sha"]
self.branches[(owner, repo)][new] = {"sha": old_sha} # Snapshot the parent branch's files at cut time so we can
# Copy main's files into the new branch # surface §10.5 merge conflicts when main diverges later.
snapshot: dict[str, str] = {}
for (o, r, br, p), data in list(self.files.items()): for (o, r, br, p), data in list(self.files.items()):
if (o, r, br) == (owner, repo, old): if (o, r, br) == (owner, repo, old):
self.files[(owner, repo, new, p)] = dict(data) 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}) return httpx.Response(201, json={"name": new})
# GET /repos/{owner}/{repo}/contents/{path}?ref=... # GET /repos/{owner}/{repo}/contents/{path}?ref=...
@@ -163,7 +213,9 @@ class FakeGitea:
content = base64.b64decode(payload["content"]).decode() content = base64.b64decode(payload["content"]).decode()
sha = self._next_sha() sha = self._next_sha()
self.files[(owner, repo, branch, fpath)] = {"content": content, "sha": 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}}) return httpx.Response(201, json={"commit": {"sha": sha}})
# PUT /repos/{owner}/{repo}/contents/{path} — update_file # PUT /repos/{owner}/{repo}/contents/{path} — update_file
@@ -174,7 +226,9 @@ class FakeGitea:
content = base64.b64decode(payload["content"]).decode() content = base64.b64decode(payload["content"]).decode()
sha = self._next_sha() sha = self._next_sha()
self.files[(owner, repo, branch, fpath)] = {"content": content, "sha": 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}}) return httpx.Response(200, json={"commit": {"sha": sha}, "content": {"sha": sha}})
# GET /repos/{owner}/{repo}/pulls?state=... # GET /repos/{owner}/{repo}/pulls?state=...
@@ -183,7 +237,7 @@ class FakeGitea:
owner, repo = m.groups() owner, repo = m.groups()
state = request.url.params.get("state", "open") state = request.url.params.get("state", "open")
items = self.pulls.get((owner, repo), []) 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) return httpx.Response(200, json=filtered)
# POST /repos/{owner}/{repo}/pulls # POST /repos/{owner}/{repo}/pulls
@@ -205,7 +259,16 @@ class FakeGitea:
"user": {"login": "rfc-bot"}, "user": {"login": "rfc-bot"},
} }
self.pulls[(owner, repo)].append(pr) 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 # POST /repos/{owner}/{repo}/pulls/{number}/merge
m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/pulls/(\d+)/merge", path) m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/pulls/(\d+)/merge", path)
@@ -213,18 +276,26 @@ class FakeGitea:
owner, repo, num = m.groups() owner, repo, num = m.groups()
for pr in self.pulls[(owner, repo)]: for pr in self.pulls[(owner, repo)]:
if pr["number"] == int(num): 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"] head_branch = pr["head"]["ref"]
for (o, r, br, p), data in list(self.files.items()): for (o, r, br, p), data in list(self.files.items()):
if (o, r, br) == (owner, repo, head_branch): if (o, r, br) == (owner, repo, head_branch):
self.files[(owner, repo, "main", p)] = dict(data) self.files[(owner, repo, "main", p)] = dict(data)
# Real Gitea: state becomes "closed" with merged=true.
pr["state"] = "closed" pr["state"] = "closed"
pr["merged"] = True pr["merged"] = True
pr["merged_at"] = "2026-05-23T01:00:00Z" pr["merged_at"] = "2026-05-23T01:00:00Z"
pr["closed_at"] = "2026-05-23T01:00:00Z" pr["closed_at"] = "2026-05-23T01:00:00Z"
new_sha = self._next_sha() # Per §10.5: a no-fast-forward merge advances main
self.branches[(owner, repo)]["main"]["sha"] = new_sha # via a new merge commit SHA, not by reusing the
return httpx.Response(200, json={"merged": True}) # 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"}) return httpx.Response(404, json={"message": "not found"})
# GET /repos/{owner}/{repo}/hooks # GET /repos/{owner}/{repo}/hooks
+106 -30
View File
@@ -119,6 +119,73 @@ flush + system message, flag creation, visibility flip, anonymous
read-but-no-contribute, stale-change refusal, and the chat-streaming read-but-no-contribute, stale-change refusal, and the chat-streaming
path with a fake provider injected. 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/<branch>/{pr-draft,open-pr}` and the `prs/<n>/*` 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 ### What's deferred from Slice 2
These were in the §8 spec but lean on infrastructure later slices These were in the §8 spec but lean on infrastructure later slices
@@ -192,41 +259,50 @@ spec:
## Next slice ## 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 The §8 within-branch surface and the §10 bridge to main now ship for
a branch and main. The work covers the `Open PR` affordance from active RFCs; the same mechanics still need to reach super-draft
§10.1 (with the §11.3 universal-public confirmation when the branch entries on the meta repo. Slice 4's unit of work is the meta-repo
is private), the §10.2 AI-drafted creation modal (title + edit branch — `edit/<slug>/<auto-name>` per §9.5 — and the
description from the diff plus the branch chat), the §10.3 review structural claim is that almost everything from §8 falls out
page (three-column, diff in the center, compressed conversation unchanged once `<slug>` resolves to a super-draft entry and
right, per-user seen-cursor accenting new hunks and new messages), `<branch>` names a meta-repo branch rather than a per-RFC-repo
the §10.4 `thread_kind='review'` threads anchored to diff hunks branch (see the §5 super-draft scoping note).
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 carryovers Slice 3 inherits — none new from the prototype; the What Slice 4 owns specifically:
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`.
The frontend needs a `PRView.jsx` sibling to `RFCView.jsx` that - §9.5's `Start Contributing` on a super-draft cutting an
inherits the §8.1 three-column shape but renders the diff instead `edit/<slug>/<auto-name>` branch on the meta repo via the bot,
of the editor. The route is `/rfc/<slug>/prs/<n>`. 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/<branch>/...` 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/<slug>/<auto-name>` cuts).
The next build session should read `SPEC.md`, `README.md`, and 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, working agreement in §19.3 continues to apply: implement the slice,
correct the spec only where running code reveals it was wrong at a correct the spec only where running code reveals it was wrong at a
structural level, accumulate new candidate topics in §19.2, do not structural level, accumulate new candidate topics in §19.2, do not
+135
View File
@@ -886,3 +886,138 @@
.diff-tooltip-no-context { .diff-tooltip-no-context {
font-size: 11px; color: #aaa; font-style: italic; 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; }
+2
View File
@@ -3,6 +3,7 @@ import { Routes, Route, Link, useNavigate } from 'react-router-dom'
import { getMe } from './api' import { getMe } from './api'
import Catalog from './components/Catalog.jsx' import Catalog from './components/Catalog.jsx'
import RFCView from './components/RFCView.jsx' import RFCView from './components/RFCView.jsx'
import PRView from './components/PRView.jsx'
import ProposalView from './components/ProposalView.jsx' import ProposalView from './components/ProposalView.jsx'
import ProposeModal from './components/ProposeModal.jsx' import ProposeModal from './components/ProposeModal.jsx'
import Landing from './components/Landing.jsx' import Landing from './components/Landing.jsx'
@@ -51,6 +52,7 @@ export default function App() {
<Routes> <Routes>
<Route path="/" element={<Welcome viewer={me.user} />} /> <Route path="/" element={<Welcome viewer={me.user} />} />
<Route path="/rfc/:slug" element={<RFCView viewer={me.user} />} /> <Route path="/rfc/:slug" element={<RFCView viewer={me.user} />} />
<Route path="/rfc/:slug/pr/:prNumber" element={<PRView viewer={me.user} />} />
<Route path="/proposals/:prNumber" element={<ProposalView viewer={me.user} onChange={() => setCatalogVersion(v => v + 1)} />} /> <Route path="/proposals/:prNumber" element={<ProposalView viewer={me.user} onChange={() => setCatalogVersion(v => v + 1)} />} />
</Routes> </Routes>
</main> </main>
+73
View File
@@ -197,6 +197,79 @@ export async function resolveThread(slug, branch, threadId) {
return jsonOrThrow(res) 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 // Stream a chat turn into a per-branch thread. Calls onChunk for each
// text fragment, onChanges when the trailing `changes` event arrives, // text fragment, onChanges when the trailing `changes` event arrives,
// and onDone at the terminal DONE marker. Returns the response headers // and onDone at the terminal DONE marker. Returns the response headers
+114
View File
@@ -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 (
<div className="modal-overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
<div className="modal pr-modal">
<div className="modal-header">
<h2>Open PR {branch}</h2>
<button className="modal-close" onClick={onClose}>×</button>
</div>
<div className="modal-body">
{branchIsPrivate && (
<div className="pr-modal-warning">
<p>
<strong>This branch is currently private.</strong> Per §11.3,
opening a PR makes the branch and its history publicly readable.
There is no notion of a private PR.
</p>
<label>
<input
type="checkbox"
checked={confirmed}
onChange={e => setConfirmed(e.target.checked)}
/>
{' '}I understand the branch will become public.
</label>
</div>
)}
<label className="modal-label">Title</label>
<input
type="text"
className="modal-input"
value={title}
onChange={e => setTitle(e.target.value)}
placeholder={drafting ? 'Drafting from the diff and chat…' : 'A one-line structural description'}
disabled={drafting || submitting}
maxLength={240}
/>
<p className="field-help">
§10.2: a one-line structural description in spec voice. What was
edited, in what way. AI-drafted; edit before submit.
</p>
<label className="modal-label">Description</label>
<textarea
className="modal-textarea"
value={description}
onChange={e => setDescription(e.target.value)}
placeholder={drafting ? 'Drafting…' : 'Two to four sentences pulling from the chat'}
disabled={drafting || submitting}
rows={7}
maxLength={8000}
/>
<p className="field-help">
§10.2: two to four sentences pulling from the branch chat
what was argued, what shifted, what the arbiters are asked
to consider.
</p>
{error && <p className="field-error">{error}</p>}
</div>
<div className="modal-actions">
<button className="btn-secondary" onClick={onClose} disabled={submitting}>Cancel</button>
<button className="btn-primary" onClick={submit} disabled={!canSubmit}>
{submitting ? 'Opening…' : 'Open PR'}
</button>
</div>
</div>
</div>
)
}
+546
View File
@@ -0,0 +1,546 @@
// PRView.jsx the §10.3 PR review page.
//
// Three-column shape per §8.1: the catalog on the left is the App
// chrome (so it sits outside this component, same as RFCView). This
// component owns the center (diff toggleable between unified and
// split) and the right (compressed conversation + inline review
// surface). A header strip carries title, editable description,
// status, the merge button, and aggregate counts.
//
// The per-user seen-cursor (§10.3) accents new hunks and new
// conversation messages on the next visit. The cursor advances on
// view; reviewers do not mark anything as read.
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import {
advancePRSeen,
editPRDescription,
getPR,
mergePR,
postPRReview,
startResolutionBranch,
withdrawPR,
} from '../api'
export default function PRView({ viewer }) {
const { slug, prNumber: prNumberParam } = useParams()
const navigate = useNavigate()
const prNumber = Number(prNumberParam)
const [pr, setPR] = useState(null)
const [error, setError] = useState(null)
const [diffMode, setDiffMode] = useState('unified') // 'unified' | 'split'
const [editingHeader, setEditingHeader] = useState(false)
const [draftTitle, setDraftTitle] = useState('')
const [draftDescription, setDraftDescription] = useState('')
const [acting, setActing] = useState(null) // 'merge' | 'withdraw' | 'resolution'
// Review-comment composer state.
const [reviewDraft, setReviewDraft] = useState(null) // { quote, anchorPayload }
const [reviewText, setReviewText] = useState('')
const refresh = useCallback(() => {
return getPR(slug, prNumber)
.then(setPR)
.catch(err => setError(err.message))
}, [slug, prNumber])
useEffect(() => { refresh() }, [refresh])
// §10.3: advance the seen cursor on view. We snapshot the most
// recent commit sha and the highest message id we render, then
// POST. Fire-and-forget; the next read returns the advanced cursor.
useEffect(() => {
if (!pr) return
const headSha = pr.merge_commit_sha || pr.head_sha
const allMessages = Object.values(pr.messages_by_thread || {}).flat()
const lastMessageId = allMessages.reduce((m, x) => Math.max(m, x.id || 0), 0)
if (!headSha && !lastMessageId) return
if (viewer == null) return
advancePRSeen(slug, prNumber, {
lastSeenCommitSha: headSha,
lastSeenMessageId: lastMessageId || null,
}).catch(() => {})
}, [pr?.head_sha, pr?.merge_commit_sha, prNumber, slug, viewer])
// header actions
const onMerge = useCallback(async () => {
if (!confirm(`Merge PR #${prNumber}? §10.5 produces a no-fast-forward merge commit on main.`)) return
setActing('merge')
setError(null)
try {
await mergePR(slug, prNumber)
await refresh()
} catch (e) {
setError(e.message)
} finally {
setActing(null)
}
}, [slug, prNumber, refresh])
const onWithdraw = useCallback(async () => {
if (!confirm(`Withdraw PR #${prNumber}? The branch is not deleted. §10.8.`)) return
setActing('withdraw')
setError(null)
try {
await withdrawPR(slug, prNumber)
await refresh()
} catch (e) {
setError(e.message)
} finally {
setActing(null)
}
}, [slug, prNumber, refresh])
const onResolutionBranch = useCallback(async () => {
if (!confirm('Start a resolution branch off main? §10.9 cuts a fresh branch, replays the diff, and opens a new PR.')) return
setActing('resolution')
setError(null)
try {
const { resolution_branch } = await startResolutionBranch(slug, prNumber)
navigate(`/rfc/${slug}?branch=${encodeURIComponent(resolution_branch)}`)
} catch (e) {
setError(e.message)
} finally {
setActing(null)
}
}, [slug, prNumber, navigate])
const startHeaderEdit = useCallback(() => {
setDraftTitle(pr?.title || '')
setDraftDescription(pr?.description || '')
setEditingHeader(true)
}, [pr])
const saveHeader = useCallback(async () => {
try {
await editPRDescription(slug, prNumber, {
title: draftTitle.trim(),
description: draftDescription.trim(),
})
setEditingHeader(false)
await refresh()
} catch (e) {
setError(e.message)
}
}, [slug, prNumber, draftTitle, draftDescription, refresh])
// review composer
const onSubmitReview = useCallback(async () => {
if (!reviewText.trim()) return
try {
await postPRReview(slug, prNumber, {
text: reviewText.trim(),
anchorPayload: reviewDraft?.anchorPayload || {},
quote: reviewDraft?.quote || null,
})
setReviewText('')
setReviewDraft(null)
await refresh()
} catch (e) {
setError(e.message)
}
}, [slug, prNumber, reviewText, reviewDraft, refresh])
// render
if (error) return <article className="entry-view"><p>Error: {error}</p></article>
if (!pr) return <article className="entry-view">Loading PR</article>
const merged = pr.state === 'merged'
const withdrawn = pr.state === 'withdrawn'
const closed = pr.state === 'closed'
const open = pr.state === 'open'
const supersededBy = pr.superseded_by_pr_number
const supersedes = pr.supersedes_pr_number
// §10.6: split messages by thread kind for the visual distinction
// §10.4 requires. Within each kind, sort by id for chronological
// order.
const threadsByKind = useMemo(() => {
const out = { chat: [], review: [], flag: [] }
for (const t of pr.threads || []) {
out[t.thread_kind]?.push(t)
}
return out
}, [pr.threads])
const seenSha = pr.seen?.last_seen_commit_sha
const seenMsgId = pr.seen?.last_seen_message_id || 0
// Compute new-since-cursor accents. A commit is "new" when the PR
// moved past the seen sha; for v1 we accent the diff whole-cloth
// when seenSha differs from current head/merge sha (a future slice
// can render per-hunk accents the cursor is in place).
const diffHasNewCommits = seenSha && seenSha !== (pr.merge_commit_sha || pr.head_sha)
return (
<div className="rfc-view pr-view">
{/* Header strip */}
<div className="pr-header">
<div className="pr-header-left">
<div className="pr-breadcrumb">
<a href={`/rfc/${slug}`}>{pr.rfc_id || pr.slug}</a>
<span className="breadcrumb-sep"></span>
<a href={`/rfc/${slug}?branch=${encodeURIComponent(pr.head_branch)}`}>{pr.head_branch}</a>
<span className="breadcrumb-sep"></span>
<strong>PR #{pr.pr_number}</strong>
</div>
{editingHeader ? (
<div className="pr-header-edit">
<input
type="text"
className="modal-input"
value={draftTitle}
onChange={e => setDraftTitle(e.target.value)}
maxLength={240}
/>
<textarea
className="modal-textarea"
value={draftDescription}
onChange={e => setDraftDescription(e.target.value)}
rows={4}
maxLength={8000}
/>
<div className="modal-actions">
<button className="btn-secondary" onClick={() => setEditingHeader(false)}>Cancel</button>
<button className="btn-primary" onClick={saveHeader}>Save</button>
</div>
</div>
) : (
<>
<h1 className="pr-title">{pr.title}</h1>
{pr.description && (
<p className="pr-description">{pr.description}</p>
)}
{pr.capabilities?.can_edit_text && (
<button className="btn-link" onClick={startHeaderEdit}>Edit title & description</button>
)}
</>
)}
</div>
<div className="pr-header-right">
<StateBanner state={pr.state} mergedAt={pr.merged_at} closedAt={pr.closed_at} />
{(supersedes || supersededBy) && (
<div className="pr-supersedes">
{supersedes && <>Supersedes <a href={`/rfc/${slug}/pr/${supersedes}`}>#{supersedes}</a></>}
{supersededBy && <>Closed by <a href={`/rfc/${slug}/pr/${supersededBy}`}>#{supersededBy}</a></>}
</div>
)}
<div className="pr-counts">
<span className="pr-count-flags" title="Open flags on the source branch (§8.13)">
{pr.counts.open_flags} flag{pr.counts.open_flags === 1 ? '' : 's'}
</span>
<span className="pr-count-reviews">
{pr.counts.open_review_threads} review thread{pr.counts.open_review_threads === 1 ? '' : 's'}
</span>
<span className="pr-count-chats">
{pr.counts.open_chat_threads} open chat{pr.counts.open_chat_threads === 1 ? '' : 's'}
</span>
</div>
{open && pr.mergeable === false && (
<div className="pr-conflict-banner">
<p>
<strong>Merge conflict with main.</strong> Per §10.9, start a
resolution branch off main's tip to replay your work.
</p>
<button
className="btn-primary"
onClick={onResolutionBranch}
disabled={acting === 'resolution'}
>
{acting === 'resolution' ? 'Starting…' : 'Start resolution branch'}
</button>
</div>
)}
<div className="pr-actions">
{pr.capabilities?.can_merge && pr.mergeable !== false && (
<button
className="btn-primary"
onClick={onMerge}
disabled={acting === 'merge'}
>
{acting === 'merge' ? 'Merging…' : 'Merge'}
</button>
)}
{pr.capabilities?.can_withdraw && (
<button
className="btn-secondary"
onClick={onWithdraw}
disabled={acting === 'withdraw'}
>
{acting === 'withdraw' ? 'Withdrawing…' : 'Withdraw'}
</button>
)}
</div>
{!viewer && (
<a className="btn-link" href="/auth/login">Sign in to review</a>
)}
</div>
</div>
{/* Two columns under the header */}
<div className="rfc-body pr-body">
<div className="editor-area">
<div className="diff-mode-toolbar">
<button
className={`btn-link ${diffMode === 'unified' ? 'active' : ''}`}
onClick={() => setDiffMode('unified')}
>Unified</button>
<button
className={`btn-link ${diffMode === 'split' ? 'active' : ''}`}
onClick={() => setDiffMode('split')}
>Split</button>
{diffHasNewCommits && (
<span className="pr-diff-accent" title="New commits since your last visit">
New since your last visit
</span>
)}
</div>
<DiffPane
mainBody={pr.main_body || ''}
branchBody={pr.branch_body || ''}
mode={diffMode}
onReviewRange={(quote, anchorPayload) => {
if (!pr.capabilities?.can_post_review) return
setReviewDraft({ quote, anchorPayload })
setReviewText('')
}}
/>
</div>
<div className="right-panel">
<PRConversation
threads={pr.threads || []}
messagesByThread={pr.messages_by_thread || {}}
threadsByKind={threadsByKind}
seenMsgId={seenMsgId}
/>
{reviewDraft && pr.capabilities?.can_post_review && (
<div className="pr-review-composer">
<div className="pr-review-quote">
<strong>Reviewing:</strong>
<pre>{reviewDraft.quote || '(no selection)'}</pre>
</div>
<textarea
className="modal-textarea"
value={reviewText}
onChange={e => setReviewText(e.target.value)}
placeholder="Leave a review comment on this range — §10.4."
rows={4}
/>
<div className="modal-actions">
<button className="btn-secondary" onClick={() => { setReviewDraft(null); setReviewText('') }}>Cancel</button>
<button className="btn-primary" onClick={onSubmitReview} disabled={!reviewText.trim()}>Post review</button>
</div>
</div>
)}
</div>
</div>
</div>
)
}
function StateBanner({ state, mergedAt, closedAt }) {
if (state === 'merged') return <div className="pr-state-banner merged">Merged {fmtDate(mergedAt)}</div>
if (state === 'withdrawn') return <div className="pr-state-banner withdrawn">Withdrawn {fmtDate(closedAt)}</div>
if (state === 'closed') return <div className="pr-state-banner closed">Closed {fmtDate(closedAt)}</div>
return <div className="pr-state-banner open">Open</div>
}
function fmtDate(iso) {
if (!iso) return ''
try { return new Date(iso).toLocaleDateString() } catch { return iso }
}
//
// Conversation surface chat + review interleaved by chronology.
// §8.5: compressed by default. We expand all for v1; the toggle and
// the "Show full conversation" affordance are tracked as a §19.2
// follow-on if usage shows it matters.
//
function PRConversation({ threads, messagesByThread, threadsByKind, seenMsgId }) {
// Flatten every message with its thread context, sort by id.
const items = []
for (const t of threads) {
const msgs = messagesByThread[t.id] || []
for (const m of msgs) {
items.push({ ...m, _thread: t })
}
if (t.thread_kind === 'flag' && msgs.length === 0) {
items.push({
id: `flag-${t.id}`,
role: 'system',
text: t.label || '',
_thread: t,
created_at: t.created_at,
})
}
}
items.sort((a, b) => {
const ai = typeof a.id === 'number' ? a.id : 0
const bi = typeof b.id === 'number' ? b.id : 0
return ai - bi
})
return (
<div className="chat-panel pr-conversation">
<div className="pr-conv-disclosure">
{threadsByKind.review.length > 0 && (
<div className="thread-disclosure">
<strong>{threadsByKind.review.length}</strong> review thread{threadsByKind.review.length === 1 ? '' : 's'}
</div>
)}
{threadsByKind.flag.length > 0 && (
<div className="thread-disclosure">
<strong>{threadsByKind.flag.length}</strong> flag{threadsByKind.flag.length === 1 ? '' : 's'}
</div>
)}
</div>
<ul className="chat-feed">
{items.map(m => {
const t = m._thread
const isReview = t?.thread_kind === 'review'
const isFlag = t?.thread_kind === 'flag'
const isNew = typeof m.id === 'number' && m.id > seenMsgId
const cls = [
'chat-msg',
`chat-msg-${m.role}`,
isReview ? 'chat-msg-review' : '',
isFlag ? 'chat-msg-flag' : '',
isNew ? 'chat-msg-new' : '',
].filter(Boolean).join(' ')
return (
<li key={m.id} className={cls} data-message-id={m.id}>
<div className="chat-msg-header">
{isReview && <span className="chat-msg-badge review">Review</span>}
{isFlag && <span className="chat-msg-badge flag">Flag</span>}
<span className="chat-msg-author">
{m.role === 'assistant' ? `Claude (${m.model_id || 'ai'})` : (m.author_display || m.author_login || (m.role === 'system' ? 'system' : ''))}
</span>
{isNew && <span className="chat-msg-new-pip" title="New since your last visit"></span>}
</div>
{m.quote && <pre className="chat-msg-quote">{m.quote}</pre>}
<div className="chat-msg-body">{m.text}</div>
</li>
)
})}
</ul>
</div>
)
}
//
// Diff pane minimal line-by-line unified/split renderer over the
// branch's RFC.md vs main's RFC.md. The §19.2 candidate "DiffView
// reconstruction from changes history" is the long-form follow-on;
// for Slice 3 we serve the file-level diff readers expect.
//
function DiffPane({ mainBody, branchBody, mode, onReviewRange }) {
const lines = useMemo(() => computeLineDiff(mainBody, branchBody), [mainBody, branchBody])
if (mode === 'split') {
return (
<div className="diff-pane diff-split">
<div className="diff-col">
<div className="diff-col-header">main</div>
{lines.map((l, i) => (
<DiffLine key={`m${i}`} side="left" type={l.type} text={l.left} onReviewRange={onReviewRange} />
))}
</div>
<div className="diff-col">
<div className="diff-col-header">{`branch`}</div>
{lines.map((l, i) => (
<DiffLine key={`b${i}`} side="right" type={l.type} text={l.right} onReviewRange={onReviewRange} />
))}
</div>
</div>
)
}
return (
<div className="diff-pane diff-unified">
{lines.map((l, i) => (
<UnifiedRow key={i} line={l} onReviewRange={onReviewRange} />
))}
</div>
)
}
function UnifiedRow({ line, onReviewRange }) {
if (line.type === 'equal') {
return <div className="diff-row diff-equal" onMouseUp={makeReviewHandler(line.left || line.right, onReviewRange)}>
<span className="diff-marker"> </span><span className="diff-text">{line.left}</span>
</div>
}
return (
<>
{line.left != null && (
<div className="diff-row diff-del" onMouseUp={makeReviewHandler(line.left, onReviewRange)}>
<span className="diff-marker"></span><span className="diff-text">{line.left}</span>
</div>
)}
{line.right != null && (
<div className="diff-row diff-add" onMouseUp={makeReviewHandler(line.right, onReviewRange)}>
<span className="diff-marker">+</span><span className="diff-text">{line.right}</span>
</div>
)}
</>
)
}
function DiffLine({ type, text, onReviewRange }) {
const cls = `diff-row diff-${type === 'equal' ? 'equal' : (text == null ? 'empty' : type)}`
return (
<div className={cls} onMouseUp={makeReviewHandler(text || '', onReviewRange)}>
<span className="diff-text">{text || ''}</span>
</div>
)
}
function makeReviewHandler(rowText, onReviewRange) {
return () => {
const sel = window.getSelection?.()
if (!sel || sel.isCollapsed) return
const quote = sel.toString()
if (!quote || !quote.trim()) return
onReviewRange?.(quote, { row_text: rowText, quote })
}
}
// Crude longest-common-subsequence-ish line diff. Sufficient for the
// single-file diff we render; switching to a proper diff library is a
// §19.2 candidate ("markdown round-trip fidelity" earned attention
// first). The output is a list of {type, left, right} rows where
// type is one of 'equal' | 'del' | 'add'.
function computeLineDiff(a, b) {
const aLines = (a || '').split('\n')
const bLines = (b || '').split('\n')
const n = aLines.length, m = bLines.length
const dp = Array(n + 1).fill(null).map(() => Array(m + 1).fill(0))
for (let i = n - 1; i >= 0; i--) {
for (let j = m - 1; j >= 0; j--) {
if (aLines[i] === bLines[j]) dp[i][j] = dp[i + 1][j + 1] + 1
else dp[i][j] = Math.max(dp[i + 1][j], dp[i][j + 1])
}
}
const out = []
let i = 0, j = 0
while (i < n && j < m) {
if (aLines[i] === bLines[j]) {
out.push({ type: 'equal', left: aLines[i], right: bLines[j] })
i++; j++
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
out.push({ type: 'del', left: aLines[i], right: null })
i++
} else {
out.push({ type: 'add', left: null, right: bLines[j] })
j++
}
}
while (i < n) { out.push({ type: 'del', left: aLines[i++], right: null }) }
while (j < m) { out.push({ type: 'add', left: null, right: bLines[j++] }) }
return out
}
+45
View File
@@ -38,6 +38,7 @@ import PromptBar from './PromptBar.jsx'
import ChatPanel from './ChatPanel.jsx' import ChatPanel from './ChatPanel.jsx'
import ChangePanel from './ChangePanel.jsx' import ChangePanel from './ChangePanel.jsx'
import DiffView from './DiffView.jsx' import DiffView from './DiffView.jsx'
import PRModal from './PRModal.jsx'
const MANUAL_IDLE_MS = 5 * 60 * 1000 // §8.6 idle window; exact value is impl detail. const MANUAL_IDLE_MS = 5 * 60 * 1000 // §8.6 idle window; exact value is impl detail.
const MANUAL_DEBOUNCE_MS = 800 const MANUAL_DEBOUNCE_MS = 800
@@ -82,6 +83,7 @@ export default function RFCView({ viewer }) {
const [isStreaming, setIsStreaming] = useState(false) const [isStreaming, setIsStreaming] = useState(false)
const [focusedChangeId, setFocusedChangeId] = useState(null) const [focusedChangeId, setFocusedChangeId] = useState(null)
const [showVisibility, setShowVisibility] = useState(false) const [showVisibility, setShowVisibility] = useState(false)
const [showPRModal, setShowPRModal] = useState(false)
// Manual-edit buffer state per §8.11. // Manual-edit buffer state per §8.11.
const [manualPending, setManualPending] = useState(null) const [manualPending, setManualPending] = useState(null)
@@ -451,6 +453,17 @@ export default function RFCView({ viewer }) {
const inDiscuss = mode === 'discuss' const inDiscuss = mode === 'discuss'
const pendingCount = changes.filter(c => c.state === 'pending').length const pendingCount = changes.filter(c => c.state === 'pending').length
// §10.1: the Open PR affordance only surfaces on a non-main branch
// that has commits ahead of main and no open PR already.
const openPRForBranch = mainView?.open_prs?.find(p => p.head_branch === branchParam) || null
const branchHasCommitsAhead = branchView && mainView && branchView.body !== (mainView.body || '')
const canOpenPR = (
branchParam !== 'main'
&& canContribute
&& !openPRForBranch
&& branchHasCommitsAhead
)
return ( return (
<div className="rfc-view"> <div className="rfc-view">
{/* Breadcrumb */} {/* Breadcrumb */}
@@ -493,6 +506,25 @@ export default function RFCView({ viewer }) {
{!viewer && ( {!viewer && (
<a className="btn-link" href="/auth/login">Sign in</a> <a className="btn-link" href="/auth/login">Sign in</a>
)} )}
{canOpenPR && (
<button
type="button"
className="btn-primary btn-open-pr"
onClick={() => setShowPRModal(true)}
title="§10.1 — open a PR from this branch against main"
>
Open PR
</button>
)}
{openPRForBranch && (
<a
className="btn-link"
href={`/rfc/${slug}/pr/${openPRForBranch.pr_number}`}
title="View the open PR for this branch"
>
PR #{openPRForBranch.pr_number}
</a>
)}
{canChangeSettings && branchParam !== 'main' && ( {canChangeSettings && branchParam !== 'main' && (
<button <button
type="button" type="button"
@@ -627,6 +659,19 @@ export default function RFCView({ viewer }) {
</div> </div>
</div> </div>
{showPRModal && (
<PRModal
slug={slug}
branch={branchParam}
branchIsPrivate={!branchView.visibility?.read_public}
onClose={() => setShowPRModal(false)}
onOpened={(prNumber) => {
setShowPRModal(false)
navigate(`/rfc/${slug}/pr/${prNumber}`)
}}
/>
)}
{showVisibility && ( {showVisibility && (
<BranchVisibilityModal <BranchVisibilityModal
slug={slug} slug={slug}