"""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