503689bf1a
Builds the three-tier authorization resolver on M1's schema spine: the most-permissive union of deployment role (§6.1), project role (§22.6), and per-RFC authority (§6.3/§12), with the §22.5 visibility gate subtractive on top (§22.7). Pure app-layer — no migration (M1 shipped the tables), no behavior change on the public default project. Verifiable on the single default project by flipping its visibility and granting/revoking roles. - app/auth.py: the §22.7 resolver primitives — project_visibility, project_member_role, project_of_rfc, is_project_superuser, can_read_project, effective write/discuss standing (can_contribute_in_project / can_discuss_in_project), require_project_readable, visible_project_ids. The three per-RFC capability helpers (can_discuss_rfc / can_contribute_to_rfc / can_invite_to_rfc) now compose all three tiers, so the ~20 call sites inherit M2 unchanged. - Read pass: the §22.5 visibility gate (404 to non-members) threaded into every RFC-resolution helper across api.py / api_branches / api_prs / api_graduation / api_discussion / api_contributions / api_invitations, plus the catalog + proposals listings filtered by visible_project_ids. - Write pass: the deep gates (branch read/contribute/owner, PR merge/withdraw/ edit, graduation, discussion resolve) fold in project_admin via is_project_superuser; the "any-contributor" branch mode routes through can_contribute_in_project; propose + claim gate on project contribute standing. Deployment-level surfaces (admin idea-PR merge/decline, account notification-mute) stay deployment-scoped. - Operator decisions: implicit-on-public (a granted deployment contributor keeps its pre-M2 write baseline on a public project, no membership row, so the N=1 case stays whole) and preserve-curation (that baseline does not override per-RFC owner curation — only an explicit project grant or a deployment owner/admin does), keeping the v0.16.0 per-RFC invite contract intact on public. - tests: test_multi_project_authz_vertical.py — 9 vertical assertions on the resolver tiers, public-unchanged regression, the gated 404 read gate, the gated contribution gate, union + subtractive visibility, and revocation. Full suite 390 passed. - docs: mark Part C M2 "(landed)" with the two operator decisions. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
317 lines
13 KiB
Python
317 lines
13 KiB
Python
"""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, viewer):
|
|
"""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). The §22.5
|
|
visibility gate is subtractive: a gated project's entries 404 to
|
|
non-members (§22.7)."""
|
|
row = db.conn().execute(
|
|
"SELECT slug, title, state, owners_json, proposed_by, project_id FROM cached_rfcs WHERE slug = ?",
|
|
(slug,),
|
|
).fetchone()
|
|
if row is None:
|
|
raise HTTPException(404, "RFC not found")
|
|
auth.require_project_readable(viewer, row["project_id"])
|
|
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 auth.is_project_superuser(viewer, auth.project_of_rfc(slug)):
|
|
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, project_id FROM cached_rfcs WHERE slug = ?",
|
|
(slug,),
|
|
).fetchone()
|
|
if row is None:
|
|
raise HTTPException(404, "RFC not found")
|
|
viewer = auth.current_user(request)
|
|
# §22.5 visibility gate (subtractive): gated → 404 to non-members.
|
|
auth.require_project_readable(viewer, row["project_id"])
|
|
|
|
from . import rfc_links # local import: avoid a module import cycle
|
|
|
|
owner = rfc_links._owner_display(db.conn(), row["owners_json"], row["proposed_by"])
|
|
|
|
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, viewer)
|
|
|
|
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, viewer)
|
|
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, viewer)
|
|
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
|