3c9109c392
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>
334 lines
13 KiB
Python
334 lines
13 KiB
Python
"""Roadmap #28 — scan submitted prose for RFC-shaped references.
|
||
|
||
The scanner splits a plain-text PR description / comment body into a list
|
||
of *segments* the frontend renders: plain-text runs interleaved with
|
||
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.
|
||
|
||
Three buckets, one scan (Parts 1–3):
|
||
|
||
* ``{"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 '<term>'"
|
||
affordance that pre-fills the propose flow.
|
||
|
||
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/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
|
||
|
||
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:
|
||
"""Word-boundary test. Hyphen and underscore count as word chars so a
|
||
match can't begin or end in the middle of a kebab/snake token."""
|
||
return c.isalnum() or c in ("-", "_")
|
||
|
||
|
||
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}
|
||
|
||
|
||
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 ""}]
|
||
|
||
out: list[dict[str, Any]] = []
|
||
buf: list[str] = []
|
||
low = text.lower()
|
||
n = len(text)
|
||
i = 0
|
||
while i < n:
|
||
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 = (term, klen)
|
||
break
|
||
if match is not None:
|
||
term, klen = match
|
||
if buf:
|
||
out.append({"type": "text", "text": "".join(buf)})
|
||
buf = []
|
||
out.append(_emit(term, text[i:i + klen]))
|
||
i += klen
|
||
else:
|
||
buf.append(text[i])
|
||
i += 1
|
||
if buf:
|
||
out.append({"type": "text", "text": "".join(buf)})
|
||
return out
|
||
|
||
|
||
def _keys_for(slug: str, title: str, rfc_id: str | None) -> Iterable[str]:
|
||
"""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:
|
||
yield rid.lower()
|
||
if title:
|
||
t = title.strip()
|
||
# Multi-word titles only — a single common word is too noisy.
|
||
if len(t) >= 2 and (" " in t or "\t" in t):
|
||
yield t.lower()
|
||
if slug:
|
||
s = slug.strip()
|
||
# Hyphenated slugs only — a single-token slug is a bare word.
|
||
if len(s) >= 2 and "-" in s:
|
||
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: 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)
|
||
|
||
def segment(self, text: str | None) -> list[dict[str, Any]]:
|
||
return segment_text(text, self._terms)
|
||
|
||
|
||
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"
|
||
|
||
|
||
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):
|
||
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
|
||
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)
|