Slice 3: the PR flow
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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 (2–4
|
||||||
|
sentences pulling from chat).
|
||||||
|
|
||||||
|
When no provider is configured we fall back to a deterministic
|
||||||
|
stub — the surface still works; the contributor just edits the
|
||||||
|
text. The fallback also matches the test seam where Slice 3
|
||||||
|
integration tests don't always inject a fake provider.
|
||||||
|
"""
|
||||||
|
if not providers:
|
||||||
|
return _stub_draft(rfc_title=rfc_title, main_body=main_body, branch_body=branch_body)
|
||||||
|
provider = providers.get(default_model) or next(iter(providers.values()))
|
||||||
|
system = (
|
||||||
|
"You are summarizing a contributor's proposed change to an RFC for an arbiter audience. "
|
||||||
|
"Output exactly two sections in this order: 'TITLE: <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"],
|
||||||
|
}
|
||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user