Slice 3: the PR flow
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+3
-1
@@ -17,7 +17,7 @@ from typing import Any
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from . import api_branches, auth, db, entry as entry_mod, cache
|
||||
from . import api_branches, api_prs, auth, db, entry as entry_mod, cache
|
||||
from .bot import Bot
|
||||
from .config import Config
|
||||
from .gitea import Gitea, GiteaError
|
||||
@@ -51,6 +51,8 @@ def make_router(
|
||||
# Slice 2: the §8 active-RFC view's endpoints live in api_branches.
|
||||
# Mounting them on the same router keeps the §17 layout flat.
|
||||
router.include_router(api_branches.make_router(config, gitea, bot, providers))
|
||||
# Slice 3: the §10 PR-flow endpoints.
|
||||
router.include_router(api_prs.make_router(config, gitea, bot, providers))
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Auth surface — extends the prototype's pattern but reads role
|
||||
|
||||
@@ -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: PRs (§10) -----
|
||||
|
||||
async def open_branch_pr(
|
||||
self,
|
||||
actor: Actor,
|
||||
*,
|
||||
owner: str,
|
||||
repo: str,
|
||||
head_branch: str,
|
||||
title: str,
|
||||
description: str,
|
||||
slug: str,
|
||||
supersedes_pr_number: int | None = None,
|
||||
) -> dict:
|
||||
"""Per §10.1: open a PR from a branch against main.
|
||||
|
||||
The PR's body carries the contributor's description, optional
|
||||
`Supersedes:` trailer for the §10.9 replay path, and the
|
||||
standard `On-behalf-of:` trailer per §6.5.
|
||||
"""
|
||||
body_lines = [description.strip()]
|
||||
if supersedes_pr_number is not None:
|
||||
body_lines += ["", f"Supersedes: #{supersedes_pr_number}"]
|
||||
body_lines += ["", _trailer(actor)]
|
||||
body = "\n".join(body_lines).strip()
|
||||
pr = await self._gitea.create_pull(
|
||||
owner,
|
||||
repo,
|
||||
title=title,
|
||||
body=body,
|
||||
head=head_branch,
|
||||
base="main",
|
||||
)
|
||||
_log(
|
||||
actor,
|
||||
"open_branch_pr",
|
||||
rfc_slug=slug,
|
||||
branch_name=head_branch,
|
||||
pr_number=pr["number"],
|
||||
details={
|
||||
"title": title,
|
||||
"supersedes": supersedes_pr_number,
|
||||
"repo": f"{owner}/{repo}",
|
||||
},
|
||||
)
|
||||
return pr
|
||||
|
||||
async def merge_branch_pr(
|
||||
self,
|
||||
actor: Actor,
|
||||
*,
|
||||
owner: str,
|
||||
repo: str,
|
||||
pr_number: int,
|
||||
head_branch: str,
|
||||
slug: str,
|
||||
) -> None:
|
||||
"""Per §10.5: no-fast-forward merge.
|
||||
|
||||
Gitea's `style='merge'` produces a merge commit; the
|
||||
per-acceptance commits from §8.6 remain individually reachable
|
||||
in main's history. The merge commit's body records the merging
|
||||
user via the `On-behalf-of:` trailer — the merge commit's
|
||||
author stays the bot (the bot is the only Git writer per §1)
|
||||
but the trailer carries the human accountability.
|
||||
"""
|
||||
subject = f"Merge branch '{head_branch}'"
|
||||
body = _trailer(actor)
|
||||
await self._gitea.merge_pull(
|
||||
owner,
|
||||
repo,
|
||||
pr_number,
|
||||
merge_message_title=subject,
|
||||
merge_message_body=body,
|
||||
style="merge",
|
||||
)
|
||||
_log(
|
||||
actor,
|
||||
"merge_branch_pr",
|
||||
rfc_slug=slug,
|
||||
branch_name=head_branch,
|
||||
pr_number=pr_number,
|
||||
details={"repo": f"{owner}/{repo}"},
|
||||
)
|
||||
|
||||
async def withdraw_branch_pr(
|
||||
self,
|
||||
actor: Actor,
|
||||
*,
|
||||
owner: str,
|
||||
repo: str,
|
||||
pr_number: int,
|
||||
head_branch: str,
|
||||
slug: str,
|
||||
reason: str = "withdraw",
|
||||
) -> None:
|
||||
"""Per §10.8: close the PR; do not delete the branch."""
|
||||
await self._gitea.close_pull(owner, repo, pr_number)
|
||||
_log(
|
||||
actor,
|
||||
"withdraw_branch_pr" if reason == "withdraw" else "supersede_branch_pr",
|
||||
rfc_slug=slug,
|
||||
branch_name=head_branch,
|
||||
pr_number=pr_number,
|
||||
details={"repo": f"{owner}/{repo}", "reason": reason},
|
||||
)
|
||||
|
||||
async def cut_resolution_branch(
|
||||
self,
|
||||
actor: Actor,
|
||||
*,
|
||||
owner: str,
|
||||
repo: str,
|
||||
original_branch: str,
|
||||
resolution_branch: str,
|
||||
slug: str,
|
||||
) -> dict:
|
||||
"""Per §10.9: cut a fresh branch off main's tip into which the
|
||||
original branch's changes will be replayed. The bot owns the
|
||||
cut; the replay itself is a sequence of commit_accepted_change
|
||||
/ manual flush operations driven by the API layer."""
|
||||
created = await self._gitea.create_branch(
|
||||
owner, repo, resolution_branch, from_branch="main"
|
||||
)
|
||||
_log(
|
||||
actor,
|
||||
"create_resolution_branch",
|
||||
rfc_slug=slug,
|
||||
branch_name=resolution_branch,
|
||||
details={
|
||||
"repo": f"{owner}/{repo}",
|
||||
"original_branch": original_branch,
|
||||
},
|
||||
)
|
||||
return created
|
||||
|
||||
async def commit_replay_change(
|
||||
self,
|
||||
actor: Actor,
|
||||
*,
|
||||
owner: str,
|
||||
repo: str,
|
||||
branch: str,
|
||||
file_path: str,
|
||||
new_content: str,
|
||||
prior_sha: str,
|
||||
original_change_id: int,
|
||||
original: str,
|
||||
proposed: str,
|
||||
reason: str,
|
||||
slug: str,
|
||||
) -> str:
|
||||
"""Per §10.9: a single replayed accept lands as its own commit on
|
||||
the resolution branch, so the §8.6 evidence shape is preserved.
|
||||
The subject mirrors `commit_accepted_change`'s but the body
|
||||
records the original change id so the resolution PR's
|
||||
conversation can stitch back to the original branch's chat."""
|
||||
subject = _subject_from_reason(reason, fallback="Replay change")
|
||||
body_lines = [
|
||||
"**Original:**",
|
||||
original.strip(),
|
||||
"",
|
||||
"**Proposed:**",
|
||||
proposed.strip(),
|
||||
]
|
||||
if reason and reason.strip():
|
||||
body_lines += ["", "**Reason:**", reason.strip()]
|
||||
body_lines += ["", f"Replayed-Change-Id: {original_change_id}"]
|
||||
body_lines += [_trailer(actor)]
|
||||
message = subject + "\n\n" + "\n".join(body_lines).strip()
|
||||
result = await self._gitea.update_file(
|
||||
owner,
|
||||
repo,
|
||||
file_path,
|
||||
content=new_content,
|
||||
sha=prior_sha,
|
||||
message=message,
|
||||
branch=branch,
|
||||
author_name=actor.display_name,
|
||||
author_email=actor.email or f"{actor.gitea_login}@users.noreply",
|
||||
)
|
||||
sha = result.get("commit", {}).get("sha") or result.get("content", {}).get("sha") or ""
|
||||
_log(
|
||||
actor,
|
||||
"replay_change",
|
||||
rfc_slug=slug,
|
||||
branch_name=branch,
|
||||
bot_commit_sha=sha,
|
||||
details={"original_change_id": original_change_id, "file_path": file_path},
|
||||
)
|
||||
return sha
|
||||
|
||||
# ----- Per-RFC repo: seeding (test/dev fixtures, future graduation) -----
|
||||
|
||||
async def ensure_rfc_repo_seed(
|
||||
self,
|
||||
actor: Actor,
|
||||
|
||||
+50
-3
@@ -218,13 +218,28 @@ async def refresh_rfc_repo(config: Config, gitea: Gitea, slug: str) -> None:
|
||||
pull["number"],
|
||||
pull.get("body") or "",
|
||||
)
|
||||
# §10.8: distinguish "user withdrew" from "Gitea closed for any
|
||||
# other reason." The bot's withdraw action lands in the actions
|
||||
# log; if we see it, surface state='withdrawn'.
|
||||
if state == "closed":
|
||||
withdrew = db.conn().execute(
|
||||
"""
|
||||
SELECT 1 FROM actions
|
||||
WHERE action_kind = 'withdraw_branch_pr'
|
||||
AND rfc_slug = ? AND pr_number = ? LIMIT 1
|
||||
""",
|
||||
(slug, pull["number"]),
|
||||
).fetchone()
|
||||
if withdrew:
|
||||
state = "withdrawn"
|
||||
merge_commit_sha = pull.get("merge_commit_sha")
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO cached_prs
|
||||
(rfc_slug, pr_kind, repo, pr_number, title, description, state,
|
||||
opened_by, opened_at, merged_at, closed_at,
|
||||
head_branch, base_branch, head_sha)
|
||||
VALUES (?, 'rfc_branch', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
head_branch, base_branch, head_sha, merge_commit_sha)
|
||||
VALUES (?, 'rfc_branch', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(repo, pr_number) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
description = excluded.description,
|
||||
@@ -232,7 +247,8 @@ async def refresh_rfc_repo(config: Config, gitea: Gitea, slug: str) -> None:
|
||||
opened_by = excluded.opened_by,
|
||||
merged_at = excluded.merged_at,
|
||||
closed_at = excluded.closed_at,
|
||||
head_sha = excluded.head_sha
|
||||
head_sha = excluded.head_sha,
|
||||
merge_commit_sha = COALESCE(excluded.merge_commit_sha, cached_prs.merge_commit_sha)
|
||||
""",
|
||||
(
|
||||
slug,
|
||||
@@ -248,8 +264,26 @@ async def refresh_rfc_repo(config: Config, gitea: Gitea, slug: str) -> None:
|
||||
head_branch,
|
||||
(pull.get("base") or {}).get("ref") or "main",
|
||||
(pull.get("head") or {}).get("sha"),
|
||||
merge_commit_sha,
|
||||
),
|
||||
)
|
||||
# §10.9: an explicit `Supersedes: #N` trailer on a merged PR's
|
||||
# body bumps the predecessor's state to closed and records the
|
||||
# supersession. The cache propagates this whether the merge came
|
||||
# via webhook or reconciler.
|
||||
if state == "merged":
|
||||
superseded = _parse_supersedes(pull.get("body") or "")
|
||||
if superseded:
|
||||
db.conn().execute(
|
||||
"""
|
||||
UPDATE cached_prs
|
||||
SET state = 'closed',
|
||||
superseded_by_pr_number = ?,
|
||||
closed_at = COALESCE(closed_at, datetime('now'))
|
||||
WHERE repo = ? AND pr_number = ? AND state = 'open'
|
||||
""",
|
||||
(pull["number"], repo_full, superseded),
|
||||
)
|
||||
|
||||
|
||||
async def refresh_meta_pulls(config: Config, gitea: Gitea) -> None:
|
||||
@@ -385,6 +419,19 @@ def _kind_from_branch(head_branch: str) -> str:
|
||||
return "idea" # fallback
|
||||
|
||||
|
||||
_SUPERSEDES_RE = None
|
||||
|
||||
|
||||
def _parse_supersedes(body: str) -> int | None:
|
||||
"""Parse a `Supersedes: #N` trailer from a PR body per §10.9."""
|
||||
import re as _re
|
||||
global _SUPERSEDES_RE
|
||||
if _SUPERSEDES_RE is None:
|
||||
_SUPERSEDES_RE = _re.compile(r"^Supersedes:\s*#(\d+)", _re.MULTILINE)
|
||||
m = _SUPERSEDES_RE.search(body or "")
|
||||
return int(m.group(1)) if m else None
|
||||
|
||||
|
||||
def _state_from_pull(pull: dict) -> str:
|
||||
if pull.get("merged"):
|
||||
return "merged"
|
||||
|
||||
@@ -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):
|
||||
# files: (owner, repo, branch, path) -> {"content": str, "sha": str}
|
||||
self.files: dict[tuple[str, str, str, str], dict] = {}
|
||||
# branches: (owner, repo) -> {branch_name -> {"sha": str, "ts": str}}
|
||||
# branches: (owner, repo) -> {branch_name -> {"sha": str, "ts": str,
|
||||
# "base_main_files": {path -> str}}}
|
||||
self.branches: dict[tuple[str, str], dict[str, dict]] = {}
|
||||
# pulls: (owner, repo) -> list[pull-dict]
|
||||
self.pulls: dict[tuple[str, str], list[dict]] = {}
|
||||
@@ -71,6 +72,48 @@ class FakeGitea:
|
||||
self._commit_counter += 1
|
||||
return f"sha{self._commit_counter:04d}"
|
||||
|
||||
def _enrich_pr(self, owner: str, repo: str, pr: dict) -> dict:
|
||||
"""Return the PR with mergeability fields filled in.
|
||||
|
||||
Gitea's PR responses carry `mergeable` and `merge_commit_sha`
|
||||
plus the head sha; for the per-RFC repo paths in §10 we mirror
|
||||
that shape.
|
||||
"""
|
||||
out = dict(pr)
|
||||
head_branch = pr["head"]["ref"]
|
||||
head_sha = (self.branches.get((owner, repo)) or {}).get(head_branch, {}).get("sha")
|
||||
out["head"] = dict(pr["head"])
|
||||
if head_sha:
|
||||
out["head"]["sha"] = head_sha
|
||||
out["mergeable"] = self._is_mergeable(owner, repo, pr) if pr["state"] == "open" else False
|
||||
return out
|
||||
|
||||
def _is_mergeable(self, owner: str, repo: str, pr: dict) -> bool:
|
||||
"""A PR is mergeable when the file content under main matches the
|
||||
branch's snapshot of main at cut-time on every path the branch
|
||||
either inherited or touched. This collapses to "no path on the
|
||||
branch has diverged from main since cut" — sufficient for the
|
||||
single-file RFC.md surface and the §10.9 conflict-replay test
|
||||
path.
|
||||
"""
|
||||
head_branch = pr["head"]["ref"]
|
||||
branch_data = self.branches.get((owner, repo), {}).get(head_branch, {})
|
||||
base_snapshot: dict[str, str] = branch_data.get("base_main_files") or {}
|
||||
# Touch every path the branch tracks plus every path on main, so a
|
||||
# file deleted on main also surfaces.
|
||||
paths = set(base_snapshot.keys())
|
||||
for (o, r, br, p) in self.files.keys():
|
||||
if (o, r, br) == (owner, repo, head_branch):
|
||||
paths.add(p)
|
||||
if (o, r, br) == (owner, repo, "main"):
|
||||
paths.add(p)
|
||||
for p in paths:
|
||||
main_content = (self.files.get((owner, repo, "main", p)) or {}).get("content")
|
||||
base_content = base_snapshot.get(p)
|
||||
if main_content != base_content:
|
||||
return False
|
||||
return True
|
||||
|
||||
def handle(self, request: httpx.Request) -> httpx.Response:
|
||||
path = request.url.path.replace("/api/v1", "", 1)
|
||||
method = request.method
|
||||
@@ -118,11 +161,18 @@ class FakeGitea:
|
||||
new = payload["new_branch_name"]
|
||||
old = payload["old_branch_name"]
|
||||
old_sha = self.branches[(owner, repo)][old]["sha"]
|
||||
self.branches[(owner, repo)][new] = {"sha": old_sha}
|
||||
# Copy main's files into the new branch
|
||||
# Snapshot the parent branch's files at cut time so we can
|
||||
# surface §10.5 merge conflicts when main diverges later.
|
||||
snapshot: dict[str, str] = {}
|
||||
for (o, r, br, p), data in list(self.files.items()):
|
||||
if (o, r, br) == (owner, repo, old):
|
||||
self.files[(owner, repo, new, p)] = dict(data)
|
||||
snapshot[p] = data["content"]
|
||||
self.branches[(owner, repo)][new] = {
|
||||
"sha": old_sha,
|
||||
"ts": "2026-05-23T00:00:00Z",
|
||||
"base_main_files": snapshot,
|
||||
}
|
||||
return httpx.Response(201, json={"name": new})
|
||||
|
||||
# GET /repos/{owner}/{repo}/contents/{path}?ref=...
|
||||
@@ -163,7 +213,9 @@ class FakeGitea:
|
||||
content = base64.b64decode(payload["content"]).decode()
|
||||
sha = self._next_sha()
|
||||
self.files[(owner, repo, branch, fpath)] = {"content": content, "sha": sha}
|
||||
self.branches[(owner, repo)][branch] = {"sha": sha, "ts": "2026-05-23T00:00:00Z"}
|
||||
br = self.branches[(owner, repo)].setdefault(branch, {})
|
||||
br["sha"] = sha
|
||||
br["ts"] = "2026-05-23T00:00:00Z"
|
||||
return httpx.Response(201, json={"commit": {"sha": sha}})
|
||||
|
||||
# PUT /repos/{owner}/{repo}/contents/{path} — update_file
|
||||
@@ -174,7 +226,9 @@ class FakeGitea:
|
||||
content = base64.b64decode(payload["content"]).decode()
|
||||
sha = self._next_sha()
|
||||
self.files[(owner, repo, branch, fpath)] = {"content": content, "sha": sha}
|
||||
self.branches[(owner, repo)][branch] = {"sha": sha, "ts": "2026-05-23T00:00:00Z"}
|
||||
br = self.branches[(owner, repo)].setdefault(branch, {})
|
||||
br["sha"] = sha
|
||||
br["ts"] = "2026-05-23T00:00:00Z"
|
||||
return httpx.Response(200, json={"commit": {"sha": sha}, "content": {"sha": sha}})
|
||||
|
||||
# GET /repos/{owner}/{repo}/pulls?state=...
|
||||
@@ -183,7 +237,7 @@ class FakeGitea:
|
||||
owner, repo = m.groups()
|
||||
state = request.url.params.get("state", "open")
|
||||
items = self.pulls.get((owner, repo), [])
|
||||
filtered = [p for p in items if (state == "all") or (p["state"] == state)]
|
||||
filtered = [self._enrich_pr(owner, repo, p) for p in items if (state == "all") or (p["state"] == state)]
|
||||
return httpx.Response(200, json=filtered)
|
||||
|
||||
# POST /repos/{owner}/{repo}/pulls
|
||||
@@ -205,7 +259,16 @@ class FakeGitea:
|
||||
"user": {"login": "rfc-bot"},
|
||||
}
|
||||
self.pulls[(owner, repo)].append(pr)
|
||||
return httpx.Response(201, json=pr)
|
||||
return httpx.Response(201, json=self._enrich_pr(owner, repo, pr))
|
||||
|
||||
# GET /repos/{owner}/{repo}/pulls/{number}
|
||||
m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/pulls/(\d+)", path)
|
||||
if method == "GET" and m:
|
||||
owner, repo, num = m.groups()
|
||||
for pr in self.pulls.get((owner, repo), []):
|
||||
if pr["number"] == int(num):
|
||||
return httpx.Response(200, json=self._enrich_pr(owner, repo, pr))
|
||||
return httpx.Response(404, json={"message": "not found"})
|
||||
|
||||
# POST /repos/{owner}/{repo}/pulls/{number}/merge
|
||||
m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/pulls/(\d+)/merge", path)
|
||||
@@ -213,18 +276,26 @@ class FakeGitea:
|
||||
owner, repo, num = m.groups()
|
||||
for pr in self.pulls[(owner, repo)]:
|
||||
if pr["number"] == int(num):
|
||||
if pr["state"] != "open":
|
||||
return httpx.Response(409, json={"message": "PR is not open"})
|
||||
if not self._is_mergeable(owner, repo, pr):
|
||||
return httpx.Response(409, json={"message": "merge conflict with main"})
|
||||
head_branch = pr["head"]["ref"]
|
||||
for (o, r, br, p), data in list(self.files.items()):
|
||||
if (o, r, br) == (owner, repo, head_branch):
|
||||
self.files[(owner, repo, "main", p)] = dict(data)
|
||||
# Real Gitea: state becomes "closed" with merged=true.
|
||||
pr["state"] = "closed"
|
||||
pr["merged"] = True
|
||||
pr["merged_at"] = "2026-05-23T01:00:00Z"
|
||||
pr["closed_at"] = "2026-05-23T01:00:00Z"
|
||||
new_sha = self._next_sha()
|
||||
self.branches[(owner, repo)]["main"]["sha"] = new_sha
|
||||
return httpx.Response(200, json={"merged": True})
|
||||
# Per §10.5: a no-fast-forward merge advances main
|
||||
# via a new merge commit SHA, not by reusing the
|
||||
# branch's tip. We mint a fresh sha to model that.
|
||||
merge_sha = self._next_sha()
|
||||
pr["merge_commit_sha"] = merge_sha
|
||||
self.branches[(owner, repo)]["main"]["sha"] = merge_sha
|
||||
self.branches[(owner, repo)]["main"]["ts"] = "2026-05-23T01:00:00Z"
|
||||
return httpx.Response(200, json={"merged": True, "merge_commit_sha": merge_sha})
|
||||
return httpx.Response(404, json={"message": "not found"})
|
||||
|
||||
# GET /repos/{owner}/{repo}/hooks
|
||||
|
||||
Reference in New Issue
Block a user