v0.29.0: #28 Parts 2+3 — create-RFC offers + contribute-to-pending requests
Extends the v0.26.0 (#28 Part 1) read-time scanner into three buckets in one pass — active link (Part 1), pending-RFC contribute offer (Part 3), create-RFC offer (Part 2) — precedence active > pending > candidate. The backend still emits only structured segments (never HTML), so the surface stays XSS-safe by construction. Part 2 — create-RFC offers: a multi-word tag from the #27 taxonomy with no defining RFC renders, for a create-rights viewer, as an inline "+ create RFC" affordance that opens the propose modal pre-filled (?propose=<term>; ProposeModal gained initialTitle). Conservative multi-word gate; broader heuristics + the Haiku path are deferred. Part 3 — contribute-to-pending offers: a term matching a super-draft renders, for a signed-in non-owner, an "ask to contribute" affordance with the owner's display name. It opens a 3-field request form (who/why/optional use-case); submitting lands a contribution_requests row (migration 024) and one actionable §15 notification per owner (new kind contribution_request_on_pending_rfc, personal-direct). The owner's inbox shows who/why/use-case inline with Accept/Decline. Accept fires #12's owner-invite flow with the requester as invitee and echoes a notification back; decline notifies the requester. Pre-merge idea PRs are out of scope. New endpoints: GET /api/rfcs/{slug}/contribution-target, POST /api/rfcs/{slug}/contribution-requests, .../{id}/accept, .../{id}/decline. The invite issue path was refactored into one reusable api_invitations.issue_invitation(...) chokepoint shared by the manual invite endpoint and Part 3's accept. Tests: 9 new (3 scanner-bucket unit + 6 e2e). Full suite 374 passing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
"""v0.29.0 / roadmap #28 Part 3 — offer-to-contribute-to-a-pending-RFC.
|
||||
|
||||
When the #28 scanner (see ``rfc_links.py``) matches a term in submitted
|
||||
PR/comment text to a **pending** RFC — a super-draft
|
||||
(``cached_rfcs.state='super-draft'``: accepted as an idea, owned, with a
|
||||
contribution surface, but not yet graduated to an active RFC) — the
|
||||
reader is offered an "ask to contribute" popover. This module is the
|
||||
backend for that flow:
|
||||
|
||||
* ``GET /api/rfcs/{slug}/contribution-target`` — what the
|
||||
contribute form needs (RFC title, owner display, the viewer's
|
||||
eligibility + whether they already have a pending ask).
|
||||
* ``POST /api/rfcs/{slug}/contribution-requests`` — submit the ask
|
||||
(who-I-am / why / optional use-case); lands a row + one §15
|
||||
notification per owner.
|
||||
* ``POST /api/rfcs/{slug}/contribution-requests/{id}/accept`` — owner:
|
||||
accept, which fires #12's owner-invite flow with the requester as the
|
||||
invitee (opening the RFC's discussion/contribution surface), then
|
||||
echoes a notification back to the requester.
|
||||
* ``POST /api/rfcs/{slug}/contribution-requests/{id}/decline`` — owner:
|
||||
decline; the request closes and the requester is notified.
|
||||
|
||||
"Pending" is scoped to a super-draft because that is the state with an
|
||||
owner to route to, a contribution surface to open, and a row in
|
||||
``cached_rfcs`` for the ``rfc_invitations`` FK the accept path reuses.
|
||||
Pre-merge idea PRs are deliberately out of scope (no contribution
|
||||
surface yet) — a documented future extension, mirroring the
|
||||
conservative scoping in ``rfc_links.py``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from . import api_invitations, auth, db, notify
|
||||
|
||||
# Field caps — generous for free text, bounded so a request row (and the
|
||||
# notification payload that carries it) can't be used to store unbounded
|
||||
# blobs. Mirrors the order-of-magnitude of the propose/tag-suggest caps.
|
||||
_WHO_MAX = 2000
|
||||
_WHY_MAX = 4000
|
||||
_USE_CASE_MAX = 4000
|
||||
_TERM_MAX = 200
|
||||
|
||||
|
||||
class ContributionRequestBody(BaseModel):
|
||||
# The term in the PR/comment text that surfaced the offer (the
|
||||
# super-draft's title/slug). Carried for the owner's context line.
|
||||
matched_term: str = Field(min_length=1, max_length=_TERM_MAX)
|
||||
who_i_am: str = Field(min_length=1, max_length=_WHO_MAX)
|
||||
why: str = Field(min_length=1, max_length=_WHY_MAX)
|
||||
use_case: str | None = Field(default=None, max_length=_USE_CASE_MAX)
|
||||
|
||||
|
||||
def _require_super_draft(slug: str):
|
||||
"""The contribute surface only operates on a *pending* RFC. 404 on
|
||||
unknown; 409 on a state that isn't a super-draft (active RFCs use the
|
||||
Part-1 link, not a contribute offer; withdrawn is closed)."""
|
||||
row = db.conn().execute(
|
||||
"SELECT slug, title, state, owners_json, proposed_by FROM cached_rfcs WHERE slug = ?",
|
||||
(slug,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "RFC not found")
|
||||
if row["state"] != "super-draft":
|
||||
raise HTTPException(409, "RFC is not a pending super-draft")
|
||||
return row
|
||||
|
||||
|
||||
def _require_request(slug: str, request_id: int):
|
||||
row = db.conn().execute(
|
||||
"""
|
||||
SELECT id, rfc_slug, requester_user_id, matched_term, who_i_am, why,
|
||||
use_case, status
|
||||
FROM contribution_requests WHERE id = ? AND rfc_slug = ?
|
||||
""",
|
||||
(request_id, slug),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "Contribution request not found")
|
||||
return row
|
||||
|
||||
|
||||
def _viewer_relationship(viewer, slug: str) -> str | None:
|
||||
"""Why this viewer can't *request* to contribute — or None if they can.
|
||||
Owners/admins already have the RFC; existing collaborators are already
|
||||
in. Both get a clear 409 rather than a useless self-request."""
|
||||
if auth.is_rfc_owner(viewer, slug) or viewer.role in ("owner", "admin"):
|
||||
return "You already own or administer this RFC."
|
||||
if auth.is_rfc_collaborator(viewer, slug):
|
||||
return "You're already a collaborator on this RFC."
|
||||
return None
|
||||
|
||||
|
||||
def make_router() -> APIRouter:
|
||||
router = APIRouter()
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# GET — what the contribute form needs to render + gate itself.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.get("/api/rfcs/{slug}/contribution-target")
|
||||
async def contribution_target(slug: str, request: Request) -> dict[str, Any]:
|
||||
row = db.conn().execute(
|
||||
"SELECT slug, title, state, owners_json, proposed_by FROM cached_rfcs WHERE slug = ?",
|
||||
(slug,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "RFC not found")
|
||||
|
||||
from . import rfc_links # local import: avoid a module import cycle
|
||||
|
||||
owner = rfc_links._owner_display(db.conn(), row["owners_json"], row["proposed_by"])
|
||||
viewer = auth.current_user(request)
|
||||
|
||||
eligible = True
|
||||
reason: str | None = None
|
||||
already_requested = False
|
||||
if row["state"] != "super-draft":
|
||||
eligible, reason = False, "This RFC is no longer pending."
|
||||
elif viewer is None:
|
||||
eligible, reason = False, "Sign in to ask to contribute."
|
||||
elif viewer.permission_state != "granted":
|
||||
eligible, reason = False, "Your beta access request is in review."
|
||||
else:
|
||||
reason = _viewer_relationship(viewer, slug)
|
||||
if reason is not None:
|
||||
eligible = False
|
||||
else:
|
||||
already_requested = bool(
|
||||
db.conn().execute(
|
||||
"""
|
||||
SELECT 1 FROM contribution_requests
|
||||
WHERE rfc_slug = ? AND requester_user_id = ? AND status = 'pending'
|
||||
LIMIT 1
|
||||
""",
|
||||
(slug, viewer.user_id),
|
||||
).fetchone()
|
||||
)
|
||||
|
||||
return {
|
||||
"slug": row["slug"],
|
||||
"title": row["title"],
|
||||
"owner": owner,
|
||||
"eligible": eligible and not already_requested,
|
||||
"reason": reason,
|
||||
"already_requested": already_requested,
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# POST — submit a contribute request.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.post("/api/rfcs/{slug}/contribution-requests")
|
||||
async def create_contribution_request(
|
||||
slug: str, body: ContributionRequestBody, request: Request
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
_require_super_draft(slug)
|
||||
|
||||
reason = _viewer_relationship(viewer, slug)
|
||||
if reason is not None:
|
||||
raise HTTPException(409, reason)
|
||||
|
||||
who_i_am = body.who_i_am.strip()
|
||||
why = body.why.strip()
|
||||
use_case = (body.use_case or "").strip() or None
|
||||
matched_term = body.matched_term.strip()
|
||||
if not who_i_am or not why:
|
||||
raise HTTPException(422, "Both 'who I am' and 'why' are required.")
|
||||
|
||||
try:
|
||||
cur = db.conn().execute(
|
||||
"""
|
||||
INSERT INTO contribution_requests
|
||||
(rfc_slug, requester_user_id, matched_term, who_i_am, why, use_case)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(slug, viewer.user_id, matched_term, who_i_am, why, use_case),
|
||||
)
|
||||
except sqlite3.IntegrityError:
|
||||
# The partial unique index — one open request per (RFC, user).
|
||||
raise HTTPException(409, "You already have a pending request to contribute to this RFC.")
|
||||
request_id = cur.lastrowid
|
||||
|
||||
# One actionable notification per owner; stamp the first onto the
|
||||
# row as the inbox-action handle (any owner can act on the request).
|
||||
notif_ids = notify.fan_out_contribution_request(
|
||||
rfc_slug=slug,
|
||||
requester_user_id=viewer.user_id,
|
||||
request_id=request_id,
|
||||
matched_term=matched_term,
|
||||
who_i_am=who_i_am,
|
||||
why=why,
|
||||
use_case=use_case,
|
||||
)
|
||||
if notif_ids:
|
||||
db.conn().execute(
|
||||
"UPDATE contribution_requests SET notification_id = ? WHERE id = ?",
|
||||
(notif_ids[0], request_id),
|
||||
)
|
||||
|
||||
return {"id": request_id, "rfc_slug": slug, "status": "pending"}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# POST — owner accepts → fire #12's invite flow.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.post("/api/rfcs/{slug}/contribution-requests/{request_id}/accept")
|
||||
async def accept_contribution_request(
|
||||
slug: str, request_id: int, request: Request
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
rfc = _require_super_draft(slug)
|
||||
if not auth.can_invite_to_rfc(viewer, slug):
|
||||
raise HTTPException(403, "Only the RFC's owner can act on contribution requests")
|
||||
|
||||
req = _require_request(slug, request_id)
|
||||
if req["status"] != "pending":
|
||||
raise HTTPException(409, f"This request was already {req['status']}.")
|
||||
|
||||
requester = db.conn().execute(
|
||||
"SELECT id, email FROM users WHERE id = ?", (req["requester_user_id"],)
|
||||
).fetchone()
|
||||
if requester is None or not (requester["email"] or "").strip():
|
||||
raise HTTPException(422, "The requester has no email address on file to invite.")
|
||||
|
||||
# Fire #12's owner-invite flow with the requester as the invitee.
|
||||
# If a pending contributor invitation already exists (the owner
|
||||
# invited them out-of-band first), reuse it rather than failing.
|
||||
try:
|
||||
invitation = api_invitations.issue_invitation(
|
||||
slug=slug,
|
||||
inviter_user_id=viewer.user_id,
|
||||
inviter_display=viewer.display_name or viewer.gitea_login or "An RFC owner",
|
||||
invitee_email=requester["email"],
|
||||
role_in_rfc="contributor",
|
||||
rfc_title=rfc["title"],
|
||||
)
|
||||
invitation_id = invitation["id"]
|
||||
except HTTPException as exc:
|
||||
if exc.status_code != 409:
|
||||
raise
|
||||
existing = db.conn().execute(
|
||||
"""
|
||||
SELECT id FROM rfc_invitations
|
||||
WHERE rfc_slug = ? AND invitee_email = ? COLLATE NOCASE
|
||||
AND role_in_rfc = 'contributor' AND status = 'pending'
|
||||
ORDER BY id DESC LIMIT 1
|
||||
""",
|
||||
(slug, requester["email"].strip()),
|
||||
).fetchone()
|
||||
invitation_id = existing["id"] if existing else None
|
||||
|
||||
db.conn().execute(
|
||||
"""
|
||||
UPDATE contribution_requests
|
||||
SET status = 'accepted', decided_at = datetime('now'),
|
||||
decided_by_user_id = ?, invitation_id = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(viewer.user_id, invitation_id, request_id),
|
||||
)
|
||||
notify.notify_contribution_decided(
|
||||
rfc_slug=slug,
|
||||
requester_user_id=req["requester_user_id"],
|
||||
decider_user_id=viewer.user_id,
|
||||
request_id=request_id,
|
||||
accepted=True,
|
||||
)
|
||||
return {"ok": True, "status": "accepted", "invitation_id": invitation_id}
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# POST — owner declines.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.post("/api/rfcs/{slug}/contribution-requests/{request_id}/decline")
|
||||
async def decline_contribution_request(
|
||||
slug: str, request_id: int, request: Request
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
_require_super_draft(slug)
|
||||
if not auth.can_invite_to_rfc(viewer, slug):
|
||||
raise HTTPException(403, "Only the RFC's owner can act on contribution requests")
|
||||
|
||||
req = _require_request(slug, request_id)
|
||||
if req["status"] != "pending":
|
||||
raise HTTPException(409, f"This request was already {req['status']}.")
|
||||
|
||||
db.conn().execute(
|
||||
"""
|
||||
UPDATE contribution_requests
|
||||
SET status = 'declined', decided_at = datetime('now'),
|
||||
decided_by_user_id = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(viewer.user_id, request_id),
|
||||
)
|
||||
notify.notify_contribution_decided(
|
||||
rfc_slug=slug,
|
||||
requester_user_id=req["requester_user_id"],
|
||||
decider_user_id=viewer.user_id,
|
||||
request_id=request_id,
|
||||
accepted=False,
|
||||
)
|
||||
return {"ok": True, "status": "declined"}
|
||||
|
||||
return router
|
||||
Reference in New Issue
Block a user