diff --git a/CHANGELOG.md b/CHANGELOG.md index 53ce97c..14a6a6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,80 @@ skip versions are the composition of each intervening adjacent release's steps in order — no A-to-B path is pre-computed beyond that. +## 0.29.0 — 2026-05-28 + +**Minor — roadmap #28 Parts 2 + 3: offer-to-create-an-RFC for strong- +candidate terms, and offer-to-contribute-to-a-pending-RFC. One auto- +applied migration (024, additive: a new `contribution_requests` table). +No config/overlay/secret change; no nginx/systemd change. A plain code +deploy + the auto-migration picks it up. Shipped from driver session +0033.0.** + +Both parts extend the v0.26.0 (#28 Part 1) read-time scanner +(`backend/app/rfc_links.py`) and its renderer +(`frontend/src/components/LinkedText.jsx`). The scanner now sorts each +matched term into one of three buckets — active link (Part 1, unchanged), +pending-RFC contribute offer (Part 3), create-RFC offer (Part 2) — in one +pass, with precedence active > pending > candidate at any position. The +backend still emits only structured segments (never HTML), so the surface +stays XSS-safe by construction. + +- **Part 2 — create-RFC offers.** A *strong-candidate* term — a + **multi-word tag** from the #27 tag taxonomy that has no defining RFC + (no active or super-draft RFC whose slug/title is that term) — renders, + for a viewer with create rights (`permission_state='granted'`), as an + inline "+ create RFC" affordance. Clicking opens the propose-RFC modal + with the term pre-filled as the title (`ProposeModal` gained an + `initialTitle`; the affordance routes via `?propose=`, read in + `App.jsx`). The heuristic is deliberately conservative — multi-word is + the same false-positive guard the title rule uses, so a single common + tag word (`identity`) is never offered. Broader candidate detection + (capitalized phrases mined from text, terms repeated across recent PRs, + or the #27 Haiku `ANTHROPIC_API_KEY` pathway) is a sanctioned but + deferred extension. +- **Part 3 — contribute-to-pending offers.** A term matching a *pending* + RFC — a super-draft (`state='super-draft'`: accepted as an idea, owned, + with a contribution surface, not yet graduated) — renders, for a + signed-in non-owner, as an inline "ask to contribute" affordance + carrying the owner's display name (" is working on an RFC for + ''"). It opens a contribute-request form (`?contribute=`) + with three fields — **who I am** (required), **why I'm asking** + (required), **what I'd use it for** (optional, mirroring #26). Submitting + lands a `contribution_requests` row and one actionable §15 inbox + notification per owner (new kind `contribution_request_on_pending_rfc`, + category `personal-direct` — so it reuses the existing + `email_personal_direct` preference, no new toggle). In the inbox the + owner sees the requester's who/why/use-case inline with **Accept** / + **Decline**. Accept fires #12's owner-invite flow with the requester as + the invitee (a `contributor` `rfc_invitations` row + the existing invite + email) and echoes a notification back to the requester; Decline closes + the request and notifies the requester. Pre-merge idea PRs (not yet in + `cached_rfcs`, no contribution surface) are deliberately out of scope — + a documented future extension. + +New endpoints (all under the existing `/api` router): +`GET /api/rfcs/{slug}/contribution-target`, +`POST /api/rfcs/{slug}/contribution-requests`, +`POST /api/rfcs/{slug}/contribution-requests/{id}/accept`, +`POST /api/rfcs/{slug}/contribution-requests/{id}/decline`. + +The owner-invite issue path was refactored into one reusable chokepoint, +`api_invitations.issue_invitation(...)`, shared by the manual invite +endpoint and Part 3's accept path so the dup-guard, token mint, insert, +and transactional email stay identical. + +Upgrade steps: + +1. Deployments **MUST** apply the auto-run migration `024` (additive: the + new `contribution_requests` table; no existing table or row is + touched). The standard deploy path runs pending migrations on start — + no manual step beyond deploying the new code. +2. No config, overlay, or secret change is required. The Part 2 candidate + affordance reuses #27's tag taxonomy; it surfaces only when the corpus + carries multi-word tags without a defining RFC, and the create + affordance renders only for beta-granted viewers. Part 3's email reuse + sends through the existing invitation SMTP path — no new key. + ## 0.28.0 — 2026-05-28 **Minor — security-hardening follow-up (Session-0026 audit, informational diff --git a/VERSION b/VERSION index 697f087..ae6dd4e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.28.0 +0.29.0 diff --git a/backend/app/api.py b/backend/app/api.py index 91b8bf8..0d77109 100644 --- a/backend/app/api.py +++ b/backend/app/api.py @@ -21,6 +21,7 @@ from pydantic import BaseModel, Field from . import ( api_admin, api_branches, + api_contributions, api_discussion, api_graduation, api_invitations, @@ -141,6 +142,10 @@ def make_router( # invited users keep read access but cannot write (v0.6.0 # contract extended to per-RFC scope). router.include_router(api_invitations.make_router()) + # v0.29.0 (roadmap item #28 Part 3): offer-to-contribute-to-a-pending + # (super-draft) RFC. Reuses the #12 invite flow (api_invitations above) + # on accept; lands the request + owner notifications via §15 notify. + router.include_router(api_contributions.make_router()) # --------------------------------------------------------------- # §17: /api/health — unauthenticated post-flight probe. diff --git a/backend/app/api_contributions.py b/backend/app/api_contributions.py new file mode 100644 index 0000000..e9f4fb8 --- /dev/null +++ b/backend/app/api_contributions.py @@ -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 diff --git a/backend/app/api_invitations.py b/backend/app/api_invitations.py index 9450d94..d6e3d1f 100644 --- a/backend/app/api_invitations.py +++ b/backend/app/api_invitations.py @@ -133,69 +133,15 @@ def make_router() -> APIRouter: "Only the RFC's owner can invite collaborators", ) - invitee_email = body.invitee_email.strip() - role_in_rfc = body.role_in_rfc - - # Refuse re-inviting an email that already has a pending - # invitation on this RFC at the same role. Different-role - # re-invite is allowed (upgrade discussant → contributor) - # — the new row supersedes the old in the UI listing's - # natural ordering, and acceptance of either picks up the - # corresponding role. - existing = db.conn().execute( - """ - SELECT id FROM rfc_invitations - WHERE rfc_slug = ? AND invitee_email = ? COLLATE NOCASE - AND role_in_rfc = ? AND status = 'pending' - LIMIT 1 - """, - (slug, invitee_email, role_in_rfc), - ).fetchone() - if existing: - raise HTTPException( - 409, - f"{invitee_email} already has a pending {role_in_rfc} invitation for this RFC", - ) - - token = _mint_token() - cur = db.conn().execute( - """ - INSERT INTO rfc_invitations - (rfc_slug, inviter_user_id, invitee_email, role_in_rfc, - token, expires_at) - VALUES (?, ?, ?, ?, ?, datetime('now', ?)) - """, - ( - slug, - viewer.user_id, - invitee_email, - role_in_rfc, - token, - f"+{INVITATION_TTL_DAYS} days", - ), - ) - invitation_id = cur.lastrowid - - # Send the email — synchronous. A send failure logs and - # returns; the row stays so the owner can recover via the - # listing (which carries the token for an out-of-band share). - _send_invitation_email( - to_address=invitee_email, + return 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=body.invitee_email, + role_in_rfc=body.role_in_rfc, rfc_title=rfc["title"], - role_in_rfc=role_in_rfc, - token=token, ) - return { - "id": invitation_id, - "rfc_slug": slug, - "invitee_email": invitee_email, - "role_in_rfc": role_in_rfc, - "status": "pending", - "token": token, - } - # --------------------------------------------------------------- # GET /api/rfcs//invitations # The owner's listing of every invitation on the RFC, regardless @@ -473,6 +419,78 @@ def _effective_status(row) -> str: return "expired" if is_past else "pending" +def issue_invitation( + *, + slug: str, + inviter_user_id: int, + inviter_display: str, + invitee_email: str, + role_in_rfc: str, + rfc_title: str, +) -> dict: + """Mint + persist + email one ``rfc_invitations`` row. + + The single chokepoint for issuing an invitation: the owner's manual + `POST /api/rfcs/{slug}/invitations` endpoint and roadmap #28 Part 3's + accept path both route through here, so the dup-guard, token mint, + insert, and transactional email stay identical. + + Refuses (409) re-inviting an email that already has a pending + invitation on this RFC at the same role. A different-role re-invite is + allowed (the discussant → contributor upgrade) — the new row + supersedes the old in the listing's natural ordering, and acceptance + of either picks up the corresponding role. + + Returns the new row's dict (including the raw token, for the owner's + out-of-band share / the caller's record-keeping). A send failure logs + and returns; the row stays so the owner can recover via the listing. + """ + invitee_email = invitee_email.strip() + existing = db.conn().execute( + """ + SELECT id FROM rfc_invitations + WHERE rfc_slug = ? AND invitee_email = ? COLLATE NOCASE + AND role_in_rfc = ? AND status = 'pending' + LIMIT 1 + """, + (slug, invitee_email, role_in_rfc), + ).fetchone() + if existing: + raise HTTPException( + 409, + f"{invitee_email} already has a pending {role_in_rfc} invitation for this RFC", + ) + + token = _mint_token() + cur = db.conn().execute( + """ + INSERT INTO rfc_invitations + (rfc_slug, inviter_user_id, invitee_email, role_in_rfc, + token, expires_at) + VALUES (?, ?, ?, ?, ?, datetime('now', ?)) + """, + (slug, inviter_user_id, invitee_email, role_in_rfc, token, f"+{INVITATION_TTL_DAYS} days"), + ) + invitation_id = cur.lastrowid + + _send_invitation_email( + to_address=invitee_email, + inviter_display=inviter_display, + rfc_title=rfc_title, + role_in_rfc=role_in_rfc, + token=token, + ) + + return { + "id": invitation_id, + "rfc_slug": slug, + "invitee_email": invitee_email, + "role_in_rfc": role_in_rfc, + "status": "pending", + "token": token, + } + + def _mint_token() -> str: """A 256-bit URL-safe token. The token shape is opaque to the consumer; the email link encodes it as a query param.""" diff --git a/backend/app/notify.py b/backend/app/notify.py index 7835c3e..55a9a93 100644 --- a/backend/app/notify.py +++ b/backend/app/notify.py @@ -270,6 +270,86 @@ def fan_out_new_beta_request( ) +def fan_out_contribution_request( + *, + rfc_slug: str, + requester_user_id: int, + request_id: int, + matched_term: str, + who_i_am: str, + why: str, + use_case: str | None, +) -> list[int]: + """Roadmap #28 Part 3: a reader asked to contribute to a pending + (super-draft) RFC. Land one actionable notification per owner and + return their ids (the caller stamps the first onto the request row as + the inbox-action handle). + + Personal-direct: the owner is the named subject of the request, so the + §15.4 email gate consults `email_personal_direct` exactly as for the + other owner-facing personal events — no new preference column is + needed. The request's three free-text fields ride along in the payload + so the inbox row can show the full ask inline without a second fetch. + Actor is the requester per §15.9. + """ + requester = db.conn().execute( + "SELECT display_name FROM users WHERE id = ?", (requester_user_id,) + ).fetchone() + display = (requester["display_name"] if requester else None) or "Someone" + details = { + "request_id": request_id, + "matched_term": matched_term, + "requester_user_id": requester_user_id, + "requester_display": display, + "who_i_am": who_i_am, + "why": why, + "use_case": use_case or "", + } + notif_ids: list[int] = [] + for recipient_id in _entry_owner_user_ids(rfc_slug): + if recipient_id == requester_user_id: + continue + notif_ids.append( + _emit_one( + recipient_user_id=recipient_id, + event_kind="contribution_request_on_pending_rfc", + category=CATEGORY_PERSONAL, + actor_user_id=requester_user_id, + rfc_slug=rfc_slug, + branch_name=None, + pr_number=None, + details=details, + ) + ) + return notif_ids + + +def notify_contribution_decided( + *, + rfc_slug: str, + requester_user_id: int, + decider_user_id: int, + request_id: int, + accepted: bool, +) -> None: + """Roadmap #28 Part 3: tell the requester an owner accepted or declined + their contribute request. On accept the requester also receives the + #12 invitation email out-of-band; this inbox row is the in-app echo + that points them at it.""" + _emit_one( + recipient_user_id=requester_user_id, + event_kind=( + "contribution_request_accepted" if accepted else "contribution_request_declined" + ), + category=CATEGORY_PERSONAL, + actor_user_id=decider_user_id, + rfc_slug=rfc_slug, + branch_name=None, + pr_number=None, + details={"request_id": request_id}, + ) + + def fan_out_chat_message( *, actor_user_id: int, @@ -769,6 +849,16 @@ def render_summary(event_kind: str, actor_display: str | None, rfc_title: str | return f"{actor} began graduating {title}." if event_kind == "pr_conflict_with_main": return f"{actor} started a resolution branch on {title}." + if event_kind == "contribution_request_on_pending_rfc": + # Roadmap #28 Part 3: owner-facing, actionable. The term is the + # super-draft reference that surfaced the offer; the inbox row + # renders Accept/Decline beneath this line. + term = extras.get("matched_term") or title + return f"{actor} wants to contribute to your pending RFC for '{term}'." + if event_kind == "contribution_request_accepted": + return f"{actor} accepted your request to contribute to {title} — check your email to accept the invitation." + if event_kind == "contribution_request_declined": + return f"{actor} declined your request to contribute to {title}." if event_kind == "new_beta_request": # v0.9.0: framework-scoped, not RFC-scoped. The actor (the # requester) and the captured full name + email read as @@ -884,6 +974,11 @@ def list_inbox( "read_at": row["read_at"], "category": extras.get("category"), "summary": render_summary(row["event_kind"], row["actor_display"], row["rfc_title"], extras), + # The row's payload, surfaced for kinds that render inline + # detail (e.g. #28 Part 3's contribute-request who/why/use-case + # + Accept/Decline). Safe to expose: a recipient only ever sees + # their own notifications. + "extras": extras, }) if bundled: diff --git a/backend/app/rfc_links.py b/backend/app/rfc_links.py index 582957f..0199dbc 100644 --- a/backend/app/rfc_links.py +++ b/backend/app/rfc_links.py @@ -1,43 +1,95 @@ -"""Roadmap #28 Part 1 — auto-link RFC references in submitted prose. +"""Roadmap #28 — scan submitted prose for RFC-shaped references. -Scans plain-text PR descriptions and comment bodies for references to -existing **accepted** (state='active') RFCs and returns a structured list +The scanner splits a plain-text PR description / comment body into a list of *segments* the frontend renders: plain-text runs interleaved with -``{"type": "rfc", ...}`` link segments. The backend never emits HTML — -the frontend maps link segments onto React anchors — so the surface is -XSS-safe by construction and independent of any HTML-sanitization layer. +typed link segments. The backend never emits HTML — the frontend maps +each segment onto a React node — so the surface is XSS-safe by +construction and independent of any HTML-sanitization layer. -**Read-time enrichment, not submit-time persistence.** The roadmap row -phrases the scan as happening "at submit/post time"; this module instead -enriches on read. The intent the roadmap actually names — "not as live -compose preview" — is honored (drafts are never scanned, only submitted -content on the read paths). Read-time was chosen for three reasons: +Three buckets, one scan (Parts 1–3): - 1. Correctness — links track the *live* active-RFC set. A newly-accepted - RFC starts linking in older comments; a withdrawn RFC stops linking - everywhere. Submit-time freezing would drift stale. - 2. Zero migration — no derived data to store. (A concurrent session - already holds migration 023; staying migration-free keeps this slice - conflict-free as well as simpler.) - 3. Cost — the active-RFC corpus is small and cache-resident, so building - the term index and scanning a ≤20k-char body per read is cheap. + * ``{"type": "rfc", ...}`` — Part 1. The term matches an + **accepted** (``state='active'``) RFC; renders as a link to it. + * ``{"type": "rfc-pending", ...}`` — Part 3. The term matches a + **pending** RFC — a super-draft (``state='super-draft'``: accepted + as an idea but not yet graduated to an active RFC) — which has an + owner and a contribution surface. Renders as an "ask to contribute" + affordance carrying the owner's display name. + * ``{"type": "rfc-candidate", ...}`` — Part 2. The term is a + strong-candidate that does **not** yet have a defining RFC. Renders + (for a viewer with create rights) as a "create RFC for ''" + affordance that pre-fills the propose flow. -**Matching is conservative by design.** Only references that are unlikely -to be coincidental link: +Precedence at any position is active > pending > candidate, then +longest-match-first — an active link always wins over a contribute offer +which always wins over a create offer for the same span. + +**Read-time enrichment, not submit-time persistence** (unchanged from +Part 1): drafts are never scanned, only submitted content on the read +paths, so links/offers track the *live* corpus. The active-RFC corpus, +super-draft corpus, and tag taxonomy are all small and cache-resident, +so building the index and scanning a ≤20k-char body per read is cheap. + +**Matching stays conservative by design.** A reference links/offers only +when it is unlikely to be coincidental: * ``rfc_id`` tokens (e.g. ``RFC-0001``) — inherently specific. * Multi-word titles (containing whitespace, e.g. ``Open Human Model``). * Hyphenated slugs (containing ``-``, e.g. ``open-human-model``). -Single common-word titles or slugs (e.g. a hypothetical RFC titled -"Human") are deliberately NOT auto-linked — they would turn every prose -"human" into a link. Surfacing those is the job of the roadmap's -"curated canonical-terms list", an explicit per-deployment opt-in left as -a future extension rather than guessed at here. +Single common-word titles/slugs are deliberately NOT matched — they +would turn every prose occurrence into an affordance. + +**Part 2 candidate heuristic.** A candidate term is a **multi-word tag** +from the #27 tag taxonomy (the de-facto set of tags the corpus already +carries) that has no defining RFC (no active or super-draft RFC whose +slug or title is that term). Multi-word is the same false-positive guard +the title rule uses: a single common tag word (``identity``) would be +far too noisy. Broader candidate detection — capitalized multi-word +phrases mined from the text, terms repeated across recently-touched PRs, +or the #27 Haiku (``ANTHROPIC_API_KEY``) pathway — is a sanctioned but +deferred extension; the conservative tag-taxonomy heuristic is chosen +here to match Part 1's false-positive-averse philosophy. """ from __future__ import annotations -from typing import Any, Iterable +import json +import re +from typing import Any, Iterable, NamedTuple + + +class Term(NamedTuple): + """One match key plus what to emit when it hits. + + ``key`` is the lowercase span to match (word-boundary, longest-first). + ``kind`` is ``'active' | 'pending' | 'candidate'`` and selects the + emitted segment shape. ``slug``/``title`` carry the target RFC (active + + pending); ``owner`` is the pending RFC's owner display name; + ``term`` is the candidate's canonical display spelling. + """ + + key: str + kind: str = "active" + slug: str = "" + title: str = "" + owner: str = "" + term: str = "" + + +# Lower number = higher precedence when two keys of equal length match at +# the same position. A real link beats a contribute offer beats a create +# offer. +_KIND_PRIORITY = {"active": 0, "pending": 1, "candidate": 2} + + +def _coerce(t: Term | tuple) -> Term: + """Accept the legacy ``(key, slug, title)`` 3-tuple (treated as an + active term) alongside :class:`Term`, so direct unit-test callers and + older call sites keep working.""" + if isinstance(t, Term): + return t + key, slug, title = t # legacy active 3-tuple + return Term(key=key, kind="active", slug=slug, title=title) def _is_word_char(c: str) -> bool: @@ -46,18 +98,37 @@ def _is_word_char(c: str) -> bool: return c.isalnum() or c in ("-", "_") -def segment_text(text: str | None, terms: list[tuple[str, str, str]]) -> list[dict[str, Any]]: - """Split ``text`` into text / rfc-link segments against ``terms``. +def _emit(term: Term, label: str) -> dict[str, Any]: + """The segment dict for a matched ``term``; ``label`` preserves source + casing.""" + if term.kind == "pending": + return { + "type": "rfc-pending", + "slug": term.slug, + "label": label, + "title": term.title, + "owner": term.owner, + } + if term.kind == "candidate": + return {"type": "rfc-candidate", "label": label, "term": term.term} + return {"type": "rfc", "slug": term.slug, "label": label, "title": term.title} - ``terms`` is a list of ``(key_lower, slug, title)`` tuples; callers - pass it pre-sorted longest-first so the longest match wins at any - position (so "Open Human Model" wins over a bare "Open"). Matching is - case-insensitive and respects word boundaries on both ends. The - returned ``label`` preserves the source casing. + +def segment_text(text: str | None, terms: Iterable[Term | tuple]) -> list[dict[str, Any]]: + """Split ``text`` into text / link segments against ``terms``. + + ``terms`` are :class:`Term` objects (or legacy ``(key, slug, title)`` + active 3-tuples). Matching is case-insensitive, respects word + boundaries on both ends, and prefers the longest key — then higher + :data:`_KIND_PRIORITY` — at any position. Always returns at least one segment; for empty/None input that is a single empty text segment, so callers can render uniformly. """ + ordered = sorted( + (_coerce(t) for t in terms), + key=lambda t: (-len(t.key), _KIND_PRIORITY.get(t.kind, 9)), + ) if not text: return [{"type": "text", "text": text or ""}] @@ -67,28 +138,23 @@ def segment_text(text: str | None, terms: list[tuple[str, str, str]]) -> list[di n = len(text) i = 0 while i < n: - match: tuple[str, str, str, int] | None = None - for key, slug, title in terms: - klen = len(key) - if klen == 0 or not low.startswith(key, i): + match: tuple[Term, int] | None = None + for term in ordered: + klen = len(term.key) + if klen == 0 or not low.startswith(term.key, i): continue before = text[i - 1] if i > 0 else "" after = text[i + klen] if i + klen < n else "" if _is_word_char(before) or _is_word_char(after): continue - match = (key, slug, title, klen) + match = (term, klen) break if match is not None: - _key, slug, title, klen = match + term, klen = match if buf: out.append({"type": "text", "text": "".join(buf)}) buf = [] - out.append({ - "type": "rfc", - "slug": slug, - "label": text[i:i + klen], - "title": title, - }) + out.append(_emit(term, text[i:i + klen])) i += klen else: buf.append(text[i]) @@ -99,8 +165,8 @@ def segment_text(text: str | None, terms: list[tuple[str, str, str]]) -> list[di def _keys_for(slug: str, title: str, rfc_id: str | None) -> Iterable[str]: - """The match keys an active RFC contributes. See the module docstring - for why each gate exists (conservative, false-positive-averse).""" + """The match keys an RFC contributes. See the module docstring for why + each gate exists (conservative, false-positive-averse).""" if rfc_id: rid = rfc_id.strip() if len(rid) >= 2: @@ -117,13 +183,23 @@ def _keys_for(slug: str, title: str, rfc_id: str | None) -> Iterable[str]: yield s.lower() +def _slugify(term: str) -> str: + """Deterministic kebab-case — mirrors the propose modal's slugify so a + tag's would-be slug compares correctly against existing RFC slugs.""" + return re.sub(r"-+$", "", re.sub(r"^-+", "", re.sub(r"[^a-z0-9]+", "-", term.lower().strip()))) + + class LinkIndex: """A reusable term index built once per request and applied to many bodies (a PR's description plus every comment on it).""" - def __init__(self, terms: list[tuple[str, str, str]]): - # Longest key first so the longest reference wins at each position. - self._terms = sorted(terms, key=lambda t: len(t[0]), reverse=True) + def __init__(self, terms: Iterable[Term | tuple]): + # Coerce + order once; segment_text re-sorts defensively but a + # pre-sorted list keeps the per-body cost to the scan itself. + self._terms: list[Term] = sorted( + (_coerce(t) for t in terms), + key=lambda t: (-len(t.key), _KIND_PRIORITY.get(t.kind, 9)), + ) def __bool__(self) -> bool: return bool(self._terms) @@ -132,27 +208,126 @@ class LinkIndex: return segment_text(text, self._terms) -def build_index(conn, *, exclude_slug: str | None = None) -> LinkIndex: - """Build a :class:`LinkIndex` from the accepted (active) RFC corpus. +def _owner_display(conn, owners_json: str | None, proposed_by: str | None) -> str: + """The display name to show for a pending RFC's owner. First entry of + ``owners_json`` resolved to its user row's display name, falling back + to the bare login, then ``proposed_by``, then a neutral noun.""" + login = None + try: + owners = json.loads(owners_json or "[]") + if isinstance(owners, list): + login = next((o for o in owners if isinstance(o, str) and o.strip()), None) + except (ValueError, TypeError): + login = None + if login: + row = conn.execute( + "SELECT display_name FROM users WHERE gitea_login = ?", (login,) + ).fetchone() + if row and row["display_name"]: + return row["display_name"] + return login + return (proposed_by or "").strip() or "the proposer" - ``exclude_slug`` drops the RFC the surrounding surface is itself scoped - to, so an RFC's own title/id/slug don't self-link inside its own PR or - discussion. ``ORDER BY slug`` makes key de-duplication deterministic - when two RFCs would contribute the same key (first slug wins).""" - rows = conn.execute( - "SELECT slug, title, rfc_id FROM cached_rfcs WHERE state = 'active' ORDER BY slug" - ).fetchall() - terms: list[tuple[str, str, str]] = [] + +def _tag_universe(conn) -> list[str]: + """Distinct tags across the cached corpus (the #27 de-facto taxonomy), + preserving original spelling; case-deduped.""" + rows = conn.execute("SELECT tags_json FROM cached_rfcs").fetchall() + out: list[str] = [] seen: set[str] = set() for r in rows: + try: + tags = json.loads(r["tags_json"] or "[]") + except (ValueError, TypeError): + continue + if not isinstance(tags, list): + continue + for t in tags: + if not isinstance(t, str): + continue + tag = t.strip() + low = tag.lower() + if tag and low not in seen: + seen.add(low) + out.append(tag) + return out + + +def build_index( + conn, + *, + exclude_slug: str | None = None, + include_pending: bool = True, + include_candidates: bool = True, +) -> LinkIndex: + """Build a :class:`LinkIndex` over the three buckets. + + ``exclude_slug`` drops the RFC the surrounding surface is itself scoped + to, so an RFC's own title/id/slug don't self-link (or self-offer) + inside its own PR or discussion. Precedence is enforced by insertion + order — active keys are added first and a later bucket never overrides + an already-claimed key. + """ + terms: list[Term] = [] + seen: set[str] = set() + + def add(key: str, term: Term) -> None: + if key in seen: + return + seen.add(key) + terms.append(term) + + # --- Part 1: accepted (active) RFCs. ORDER BY slug makes key + # de-duplication deterministic when two RFCs would contribute the + # same key (first slug wins). --- + active_rows = conn.execute( + "SELECT slug, title, rfc_id FROM cached_rfcs WHERE state = 'active' ORDER BY slug" + ).fetchall() + # Track every slug + title that *has* a defining RFC, so Part 2 never + # offers to create one that already exists (active or pending). + defined_slugs: set[str] = set() + defined_titles: set[str] = set() + for r in active_rows: slug = r["slug"] + defined_slugs.add((slug or "").lower()) + defined_titles.add((r["title"] or "").strip().lower()) if exclude_slug is not None and slug == exclude_slug: continue title = r["title"] or "" rfc_id = r["rfc_id"] if "rfc_id" in r.keys() else None for key in _keys_for(slug, title, rfc_id): - if key in seen: + add(key, Term(key=key, kind="active", slug=slug, title=title)) + + # --- Part 3: pending (super-draft) RFCs. --- + pending_rows = conn.execute( + """ + SELECT slug, title, rfc_id, owners_json, proposed_by + FROM cached_rfcs WHERE state = 'super-draft' ORDER BY slug + """ + ).fetchall() + for r in pending_rows: + slug = r["slug"] + defined_slugs.add((slug or "").lower()) + defined_titles.add((r["title"] or "").strip().lower()) + if not include_pending: + continue + if exclude_slug is not None and slug == exclude_slug: + continue + title = r["title"] or "" + rfc_id = r["rfc_id"] if "rfc_id" in r.keys() else None + owner = _owner_display(conn, r["owners_json"], r["proposed_by"]) + for key in _keys_for(slug, title, rfc_id): + add(key, Term(key=key, kind="pending", slug=slug, title=title, owner=owner)) + + # --- Part 2: strong-candidate terms with no defining RFC. --- + if include_candidates: + for tag in _tag_universe(conn): + low = tag.lower() + # Conservative: multi-word tags only (same guard as titles). + if " " not in tag and "\t" not in tag: continue - seen.add(key) - terms.append((key, slug, title)) + if low in defined_titles or low in defined_slugs or _slugify(tag) in defined_slugs: + continue + add(low, Term(key=low, kind="candidate", term=tag)) + return LinkIndex(terms) diff --git a/backend/migrations/024_contribution_requests.sql b/backend/migrations/024_contribution_requests.sql new file mode 100644 index 0000000..4ddfc57 --- /dev/null +++ b/backend/migrations/024_contribution_requests.sql @@ -0,0 +1,59 @@ +-- v0.29.0 / roadmap #28 Part 3 — offer-to-contribute-to-a-pending-RFC. +-- +-- When the #28 scanner matches a term in submitted PR/comment text to a +-- *pending* RFC (a super-draft: accepted-as-an-idea but not yet graduated +-- to an active RFC), the reader is offered a "ask to contribute" popover. +-- Submitting it lands a row here AND a notification in each owner's §15 +-- inbox; the owner can accept (which fires #12's owner-invite flow with +-- the requester as the invitee) or decline (the requester is notified and +-- the request closes). +-- +-- A "pending RFC" is scoped to a super-draft (cached_rfcs.state = +-- 'super-draft'): it is in cached_rfcs (so the rfc_invitations FK that the +-- accept path reuses resolves), it carries owners (owners_json) to route +-- the request to, and it already has a discussion/contribution surface to +-- open. Pre-merge idea PRs (not yet in cached_rfcs, no contribution +-- surface) are deliberately out of scope — see backend/app/rfc_links.py. +-- +-- The request row is the persistent record; the inbox notification is the +-- owner-facing actionable surface keyed back to it via `notification_id`. + +CREATE TABLE IF NOT EXISTS contribution_requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + rfc_slug TEXT NOT NULL + REFERENCES cached_rfcs(slug) ON DELETE CASCADE, + requester_user_id INTEGER NOT NULL + REFERENCES users(id) ON DELETE CASCADE, + -- The term in the PR/comment text that surfaced the offer (e.g. the + -- super-draft's title). Carried for the owner's context line and the + -- requester's "what RFC" anchor; not a foreign key. + matched_term TEXT NOT NULL, + -- The three contribute-request fields (§15 / #26 vocabulary). + -- `who_i_am` and `why` are required; `use_case` mirrors #26's + -- optional ground-truth field. + who_i_am TEXT NOT NULL, + why TEXT NOT NULL, + use_case TEXT, + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'accepted', 'declined')), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + decided_at TEXT, + decided_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + -- The rfc_invitations row minted on accept (the #12 reuse), and the + -- owner-facing notification row that carries the Accept/Decline action. + invitation_id INTEGER REFERENCES rfc_invitations(id) ON DELETE SET NULL, + notification_id INTEGER REFERENCES notifications(id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS idx_contribution_requests_rfc + ON contribution_requests(rfc_slug, status); + +CREATE INDEX IF NOT EXISTS idx_contribution_requests_requester + ON contribution_requests(requester_user_id, status); + +-- At most one open (pending) request per (RFC, requester): a second ask +-- while one is still pending is a 409, not a duplicate row. A decided +-- request (accepted/declined) does not block a fresh ask later. +CREATE UNIQUE INDEX IF NOT EXISTS idx_contribution_requests_one_open + ON contribution_requests(rfc_slug, requester_user_id) + WHERE status = 'pending'; diff --git a/backend/tests/test_contributions_vertical.py b/backend/tests/test_contributions_vertical.py new file mode 100644 index 0000000..f77d152 --- /dev/null +++ b/backend/tests/test_contributions_vertical.py @@ -0,0 +1,236 @@ +"""v0.29.0 / roadmap #28 Parts 2 & 3 — create-RFC offers + contribute-to- +pending requests. + +Two layers, mirroring test_rfc_links_vertical.py: + + * The PR-view scanner surfaces `rfc-pending` (Part 3) and `rfc-candidate` + (Part 2) segments alongside Part 1's `rfc` links. + * The contribute-request flow end-to-end: a non-owner asks, each owner + gets an actionable §15 notification, accept fires #12's invite flow, + decline notifies the requester. + +Reuses the FakeGitea + seed/session helpers from the existing suites. +""" +from __future__ import annotations + +import json + +from fastapi.testclient import TestClient + +from app import db + +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 +from test_super_draft_vertical import seed_super_draft +from test_rfc_links_vertical import _open_pr_on + + +def _set_owner(slug: str, login: str) -> None: + db.conn().execute( + "UPDATE cached_rfcs SET owners_json = ? WHERE slug = ?", + (json.dumps([login]), slug), + ) + + +def _set_tags(slug: str, tags: list[str]) -> None: + db.conn().execute( + "UPDATE cached_rfcs SET tags_json = ? WHERE slug = ?", + (json.dumps(tags), slug), + ) + + +def _segs(segments, kind): + return [s for s in segments if s["type"] == kind] + + +# --------------------------------------------------------------------------- +# Part 2 + Part 3 — scanner surfaces on the PR view +# --------------------------------------------------------------------------- + + +def test_pending_and_candidate_segments_on_pr(app_with_fake_gitea): + app, fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=2, login="alice", role="contributor") + # Host active RFC (OHM — single word, contributes no keys itself) + # carrying a multi-word tag with no defining RFC: the Part 2 + # candidate. And a pending super-draft owned by alice: the Part 3 + # contribute target. + seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY) + _set_tags("ohm", ["memory model", "identity"]) + seed_super_draft(fake, slug="open-human-model", title="Open Human Model", + pitch="A framework for representing humans.", proposed_by="alice") + _set_owner("open-human-model", "alice") + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") + + pr_number = _open_pr_on( + client, fake, host_slug="ohm", + description="This builds on the Open Human Model and the memory model.", + ) + pr = client.get(f"/api/rfcs/ohm/prs/{pr_number}").json() + segs = pr["description_segments"] + + pending = _segs(segs, "rfc-pending") + assert len(pending) == 1 + assert pending[0]["slug"] == "open-human-model" + assert pending[0]["label"] == "Open Human Model" + assert pending[0]["owner"] == "Alice" # display_name of the owner + + candidate = _segs(segs, "rfc-candidate") + assert len(candidate) == 1 + assert candidate[0]["term"] == "memory model" + # "identity" is a single-word tag — deliberately NOT a candidate. + assert all("identity" not in s.get("term", "") for s in candidate) + + +# --------------------------------------------------------------------------- +# Part 3 — the contribute-request flow +# --------------------------------------------------------------------------- + + +def _seed_pending_owned_by_alice(fake): + provision_user_row(user_id=2, login="alice", role="contributor") + provision_user_row(user_id=3, login="bob", role="contributor") + seed_super_draft(fake, slug="open-human-model", title="Open Human Model", + pitch="A framework.", proposed_by="alice") + _set_owner("open-human-model", "alice") + + +_REQUEST = { + "matched_term": "Open Human Model", + "who_i_am": "Bob, a researcher", + "why": "I have relevant prior work to bring.", + "use_case": "Building an identity tool.", +} + + +def test_request_accept_invites_and_notifies(app_with_fake_gitea): + app, fake = app_with_fake_gitea + with TestClient(app) as client: + _seed_pending_owned_by_alice(fake) + + # Bob asks to contribute. + sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", + role="contributor", email="bob@test") + r = client.post("/api/rfcs/open-human-model/contribution-requests", json=_REQUEST) + assert r.status_code == 200, r.text + request_id = r.json()["id"] + assert r.json()["status"] == "pending" + + # A second ask while pending is a 409, not a duplicate row. + assert client.post("/api/rfcs/open-human-model/contribution-requests", + json=_REQUEST).status_code == 409 + + # Alice (owner) sees the actionable notification with full detail. + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", + role="contributor", email="alice@test") + inbox = client.get("/api/notifications").json() + reqs = [i for i in inbox["items"] + if i["event_kind"] == "contribution_request_on_pending_rfc"] + assert len(reqs) == 1 + assert "wants to contribute" in reqs[0]["summary"] + assert reqs[0]["extras"]["who_i_am"] == "Bob, a researcher" + assert reqs[0]["extras"]["request_id"] == request_id + + # Alice accepts → #12 invitation minted for bob's email. + acc = client.post(f"/api/rfcs/open-human-model/contribution-requests/{request_id}/accept") + assert acc.status_code == 200, acc.text + assert acc.json()["status"] == "accepted" + assert acc.json()["invitation_id"] + + inv = db.conn().execute( + "SELECT invitee_email, role_in_rfc, status FROM rfc_invitations " + "WHERE rfc_slug = 'open-human-model'" + ).fetchone() + assert inv["invitee_email"] == "bob@test" + assert inv["role_in_rfc"] == "contributor" + assert inv["status"] == "pending" + + # The request is settled — re-accepting is a 409. + assert client.post( + f"/api/rfcs/open-human-model/contribution-requests/{request_id}/accept" + ).status_code == 409 + + # Bob gets the accepted echo in his inbox. + sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", + role="contributor", email="bob@test") + bob_kinds = [i["event_kind"] for i in client.get("/api/notifications").json()["items"]] + assert "contribution_request_accepted" in bob_kinds + + +def test_decline_notifies_requester(app_with_fake_gitea): + app, fake = app_with_fake_gitea + with TestClient(app) as client: + _seed_pending_owned_by_alice(fake) + sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", + role="contributor", email="bob@test") + request_id = client.post( + "/api/rfcs/open-human-model/contribution-requests", json=_REQUEST + ).json()["id"] + + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", + role="contributor", email="alice@test") + dec = client.post(f"/api/rfcs/open-human-model/contribution-requests/{request_id}/decline") + assert dec.status_code == 200, dec.text + assert dec.json()["status"] == "declined" + + row = db.conn().execute( + "SELECT status FROM contribution_requests WHERE id = ?", (request_id,) + ).fetchone() + assert row["status"] == "declined" + + sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", + role="contributor", email="bob@test") + bob_kinds = [i["event_kind"] for i in client.get("/api/notifications").json()["items"]] + assert "contribution_request_declined" in bob_kinds + + +def test_owner_cannot_request_own_rfc(app_with_fake_gitea): + app, fake = app_with_fake_gitea + with TestClient(app) as client: + _seed_pending_owned_by_alice(fake) + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", + role="contributor", email="alice@test") + r = client.post("/api/rfcs/open-human-model/contribution-requests", json=_REQUEST) + assert r.status_code == 409 + assert "own" in r.json()["detail"].lower() + + +def test_request_on_active_rfc_rejected(app_with_fake_gitea): + # The contribute offer only exists for pending super-drafts; an active + # RFC uses the Part-1 link instead. + app, fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=3, login="bob", role="contributor") + seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY) + sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", + role="contributor", email="bob@test") + r = client.post("/api/rfcs/ohm/contribution-requests", json=_REQUEST) + assert r.status_code == 409 + + +def test_contribution_target_eligibility(app_with_fake_gitea): + app, fake = app_with_fake_gitea + with TestClient(app) as client: + _seed_pending_owned_by_alice(fake) + + # Anonymous: not eligible, told to sign in. + t = client.get("/api/rfcs/open-human-model/contribution-target").json() + assert t["eligible"] is False + assert "sign in" in (t["reason"] or "").lower() + assert t["owner"] == "Alice" + + # Bob: eligible until he has a pending ask, then not. + sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob", + role="contributor", email="bob@test") + assert client.get("/api/rfcs/open-human-model/contribution-target").json()["eligible"] is True + client.post("/api/rfcs/open-human-model/contribution-requests", json=_REQUEST) + after = client.get("/api/rfcs/open-human-model/contribution-target").json() + assert after["already_requested"] is True + assert after["eligible"] is False diff --git a/backend/tests/test_rfc_links_vertical.py b/backend/tests/test_rfc_links_vertical.py index 304aacc..ac31ee9 100644 --- a/backend/tests/test_rfc_links_vertical.py +++ b/backend/tests/test_rfc_links_vertical.py @@ -94,6 +94,44 @@ def test_longest_match_wins(): assert out[-1]["label"] == "Open Human Model" +def test_pending_term_emits_contribute_segment(): + # Part 3: a super-draft match is an `rfc-pending` segment carrying the + # owner display name, not a plain link. + idx = rfc_links.LinkIndex([ + rfc_links.Term(key="open human model", kind="pending", + slug="open-human-model", title="Open Human Model", owner="Alice"), + ]) + out = idx.segment("see Open Human Model please") + assert out[1] == { + "type": "rfc-pending", "slug": "open-human-model", + "label": "Open Human Model", "title": "Open Human Model", "owner": "Alice", + } + + +def test_candidate_term_emits_create_segment(): + # Part 2: a candidate term carries its canonical spelling for the + # propose pre-fill; no slug (no RFC exists yet). + idx = rfc_links.LinkIndex([ + rfc_links.Term(key="memory model", kind="candidate", term="Memory Model"), + ]) + out = idx.segment("the memory model is unspecified") + assert out[1] == {"type": "rfc-candidate", "label": "memory model", "term": "Memory Model"} + + +def test_kind_precedence_active_beats_pending_beats_candidate(): + # All three buckets contribute the same key; the highest-precedence + # kind (active) must win at the position. + key = "open human model" + idx = rfc_links.LinkIndex([ + rfc_links.Term(key=key, kind="candidate", term="Open Human Model"), + rfc_links.Term(key=key, kind="pending", slug="ohm-draft", title="Open Human Model", owner="A"), + rfc_links.Term(key=key, kind="active", slug="open-human-model", title="Open Human Model"), + ]) + out = idx.segment("the Open Human Model") + assert out[-1]["type"] == "rfc" + assert out[-1]["slug"] == "open-human-model" + + def test_keys_for_gating(): keys = lambda **kw: set(rfc_links._keys_for(**kw)) # rfc_id always contributes. diff --git a/frontend/package.json b/frontend/package.json index 6beccd6..6aac60b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "rfc-app-frontend", "private": true, - "version": "0.28.0", + "version": "0.29.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.css b/frontend/src/App.css index 8109b9d..db6f42e 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1365,6 +1365,30 @@ font-weight: 500; } .rfc-autolink:hover { text-decoration-style: solid; } + +/* #28 Parts 2–3: a matched term that isn't a live link but carries an + offer (contribute to a pending RFC / create a new one). The term reads + as enriched (dotted underline, no link colour); the offer is a small + trailing chip so the prose stays readable. */ +.rfc-pending, .rfc-candidate { + text-decoration: underline; + text-decoration-style: dotted; + text-underline-offset: 2px; +} +.rfc-offer { + margin-left: 4px; + padding: 0 5px; + font-size: 0.74em; + font-weight: 600; + line-height: 1.5; + border-radius: 6px; + white-space: nowrap; + text-decoration: none; + border: 1px solid var(--color-border, #ccc); + color: var(--color-link); +} +.rfc-offer:hover { background: var(--color-surface-alt, rgba(0,0,0,0.04)); } +.rfc-offer-create { border-style: dashed; } .pr-header-edit { display: flex; flex-direction: column; gap: 8px; } .pr-header-right { display: flex; flex-direction: column; align-items: flex-end; gap: 8px; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 47c4a96..8f4bf6e 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react' -import { Routes, Route, Link, Navigate, useLocation, useNavigate } from 'react-router-dom' +import { Routes, Route, Link, Navigate, useLocation, useNavigate, useSearchParams } from 'react-router-dom' import { getMe, subscribeToNotifications } from './api' import { anonymize, EVENTS, identify, track } from './lib/analytics' import { useLastState } from './lib/useLastState' @@ -9,6 +9,7 @@ import RFCView from './components/RFCView.jsx' import PRView from './components/PRView.jsx' import ProposalView from './components/ProposalView.jsx' import ProposeModal from './components/ProposeModal.jsx' +import ContributeRequestForm from './components/ContributeRequestForm.jsx' import Landing from './components/Landing.jsx' import Login from './components/Login.jsx' import BetaPending from './components/BetaPending.jsx' @@ -51,6 +52,19 @@ export default function App() { const [identifyReady, setIdentifyReady] = useState(false) const navigate = useNavigate() const location = useLocation() + // #28 Parts 2–3: the LinkedText create/contribute affordances route via + // query params so they need no prop-threading from deep in a comment + // list. `?propose=` opens the propose modal pre-filled; + // `?contribute=&term=` opens the contribute-request form. + const [searchParams, setSearchParams] = useSearchParams() + const proposeParam = searchParams.get('propose') + const contributeSlug = searchParams.get('contribute') + const contributeTerm = searchParams.get('term') + const clearParams = (...keys) => { + const next = new URLSearchParams(searchParams) + keys.forEach(k => next.delete(k)) + setSearchParams(next, { replace: true }) + } // v0.15.0 — Page Viewed event taxonomy. We fire on every // route change; the analytics wrapper itself decides whether // anything ships out (consent + key check). The first fire is @@ -313,17 +327,26 @@ export default function App() { } /> - {proposeOpen && viewer && ( + {(proposeOpen || proposeParam != null) && viewer && ( setProposeOpen(false)} + initialTitle={proposeParam || ''} + onClose={() => { setProposeOpen(false); clearParams('propose') }} onSubmitted={({ pr_number }) => { setProposeOpen(false) + clearParams('propose') setCatalogVersion(v => v + 1) navigate(`/proposals/${pr_number}`) }} /> )} + {contributeSlug && viewer && ( + clearParams('contribute', 'term')} + /> + )} {inboxOpen && viewer && ( setInboxOpen(false)} lastChangeTick={inboxTick} /> )} diff --git a/frontend/src/api.js b/frontend/src/api.js index 76ef5da..902eea1 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -226,6 +226,40 @@ export async function suggestTags({ title, pitch, useCase }) { } } +// Roadmap #28 Part 3: offer-to-contribute-to-a-pending-RFC. +// `contributionTarget` feeds the contribute form (RFC title, owner +// display, the viewer's eligibility); `requestContribution` submits the +// ask; accept/decline are the owner's inbox actions. +export async function contributionTarget(slug) { + return jsonOrThrow(await fetch(`/api/rfcs/${slug}/contribution-target`)) +} + +export async function requestContribution(slug, { matchedTerm, whoIAm, why, useCase }) { + const res = await fetch(`/api/rfcs/${slug}/contribution-requests`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + matched_term: matchedTerm, + who_i_am: whoIAm, + why, + use_case: useCase || null, + }), + }) + return jsonOrThrow(res) +} + +export async function acceptContributionRequest(slug, requestId) { + return jsonOrThrow(await fetch( + `/api/rfcs/${slug}/contribution-requests/${requestId}/accept`, { method: 'POST' }, + )) +} + +export async function declineContributionRequest(slug, requestId) { + return jsonOrThrow(await fetch( + `/api/rfcs/${slug}/contribution-requests/${requestId}/decline`, { method: 'POST' }, + )) +} + export async function mergeProposal(prNumber) { const res = await fetch(`/api/proposals/${prNumber}/merge`, { method: 'POST' }) return jsonOrThrow(res) diff --git a/frontend/src/components/ContributeRequestForm.jsx b/frontend/src/components/ContributeRequestForm.jsx new file mode 100644 index 0000000..ac4f088 --- /dev/null +++ b/frontend/src/components/ContributeRequestForm.jsx @@ -0,0 +1,163 @@ +// ContributeRequestForm.jsx — roadmap #28 Part 3. +// +// The "ask to contribute" popover, opened from an `rfc-pending` affordance +// in LinkedText (App reads `?contribute=&term=`). It loads the +// contribution target (RFC title + owner display + the viewer's +// eligibility), shows the framing line " is working on an RFC for +// ''", and collects the three #15/#26-vocabulary fields: +// +// * Who I am (required, free-text) +// * Why I'm asking (required, free-text) +// * What I'd use it for (optional, mirrors #26) +// +// Submitting POSTs the request, which lands in each owner's §15 inbox. +// When the viewer isn't eligible (anonymous, already a collaborator, or +// has a pending ask) the form shows the backend's reason instead. + +import { useEffect, useState } from 'react' +import { contributionTarget, requestContribution } from '../api' + +export default function ContributeRequestForm({ slug, term, onClose }) { + const [target, setTarget] = useState(null) + const [loadError, setLoadError] = useState(null) + const [whoIAm, setWhoIAm] = useState('') + const [why, setWhy] = useState('') + const [useCase, setUseCase] = useState('') + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + const [done, setDone] = useState(false) + + useEffect(() => { + let live = true + contributionTarget(slug) + .then(t => { if (live) setTarget(t) }) + .catch(err => { if (live) setLoadError(err.message || 'Could not load this RFC.') }) + return () => { live = false } + }, [slug]) + + async function handleSubmit(e) { + e.preventDefault() + if (!whoIAm.trim() || !why.trim()) return + setSubmitting(true) + setError(null) + try { + await requestContribution(slug, { + matchedTerm: term || target?.title || slug, + whoIAm: whoIAm.trim(), + why: why.trim(), + useCase: useCase.trim() || null, + }) + setDone(true) + } catch (err) { + setError(err.message || 'Could not send your request.') + } finally { + setSubmitting(false) + } + } + + const owner = target?.owner || 'The owner' + const label = term || target?.title || slug + + return ( +
{ if (e.target === e.currentTarget) onClose() }}> +
+
+

Ask to contribute

+ +
+ + {loadError && ( +

{loadError}

+ )} + + {!loadError && done && ( + <> +
+

+ Your request has been sent to {owner}. You'll hear back + in your inbox; if it's accepted you'll get an invitation by email to + join the RFC. +

+
+
+ +
+ + )} + + {!loadError && !done && target && !target.eligible && ( + <> +
+

+ {owner} is working on an RFC for '{label}'. +

+

{target.already_requested + ? "You've already asked to contribute to this RFC — the owner has your request." + : (target.reason || 'You cannot ask to contribute to this RFC right now.')}

+
+
+ +
+ + )} + + {!loadError && !done && target && target.eligible && ( +
+
+

+ {owner} is working on an RFC for '{label}'. + Tell them a little about why you'd like to contribute. +

+ + +