diff --git a/CHANGELOG.md b/CHANGELOG.md index 5151836..fcd0abf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,72 @@ 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.26.0 — 2026-05-28 + +**Minor — no schema migration, no new secret, no config, no upgrade +steps. Additive read-time enrichment; deployments inherit it on deploy +with nothing to set.** Roadmap item #28 **Part 1**: references to existing +**accepted** RFCs inside PR descriptions and comment text now render as +inline links to the referenced RFC. Shipped from driver session 0029.0, +in parallel with the v0.25.0 security-hardening session — hence the +version-slot gap (0.25.0 is that session's; this took the next free slot +per the roadmap's "claims the next available version number" rule). + +Parts 2 (offer-to-create-RFC for strong-candidate terms) and 3 +(offer-to-contribute-to-a-pending-RFC) of item #28 are deliberately **not** +in this release — Part 1 ships first as the easy win, exactly as the +roadmap row anticipates. + +- **Where it links.** The PR review page's description and review + comments (`GET /api/rfcs/{slug}/prs/{n}`), and the PR-less per-RFC + discussion comments (`GET /api/rfcs/{slug}/discussion/threads/{id}/messages`). + Branch-chat (the AI-collaboration surface) is intentionally out of + scope for Part 1 — a noted follow-up. +- **Read-time, not submit-time.** The roadmap row phrases the scan as + happening "at submit/post time"; this ships it as **read-time** + enrichment instead. The intent the row actually names — "not as live + compose preview" — holds (drafts are never scanned, only submitted + content on read). Read-time was chosen because (a) links track the + *live* accepted-RFC set — a newly-accepted RFC starts linking in older + comments, a withdrawn one stops linking everywhere — rather than + freezing stale at submit; (b) it needs **no migration** (no derived + data to persist); (c) the active-RFC corpus is small and cache-resident, + so per-read scanning is cheap. (Recorded as a §19.3-rule-2 spec note in + the session transcript.) +- **XSS-safe by construction.** The backend returns structured *segments* + (a list of `{type:"text"}` / `{type:"rfc", slug, label, title}` items), + never HTML. The new `LinkedText` frontend component maps segments onto + React text nodes and anchors — no `dangerouslySetInnerHTML` — so a + comment author cannot inject markup through this path. Every enriched + field keeps its raw `text`/`description` alongside the `*_segments`, so + any non-segment-aware caller is unaffected. +- **Conservative matching (false-positive-averse).** A reference links + only when it is unlikely to be coincidental: an `rfc_id` token + (`RFC-0001`), a **multi-word** title (`Open Human Model`), or a + **hyphenated** slug (`open-human-model`). A single common-word title or + slug (a hypothetical RFC titled "Human") is **not** auto-linked — that + would turn every prose "human" into a link. Matching is case-insensitive, + word-boundary-anchored, longest-match-wins, and suppresses an RFC's + self-references inside its own PR/discussion. The roadmap's "curated + canonical-terms list" remains an explicit future opt-in rather than a + guessed-at default. New module: `backend/app/rfc_links.py`. + +Tests: 12 new (`test_rfc_links_vertical.py`) — 9 scanner units (gating +rules, word boundaries, longest-match, case-insensitivity, casing +preservation, the rfc_id/multi-word/hyphenated gates) plus 3 end-to-end +(PR description + review comment + discussion comment all surface +`*_segments`; self-reference suppression). Full suite 363 green; frontend +builds clean. + +Upgrade steps: + +1. None. This release is purely additive: no migration, no new + environment variable, no secret, no overlay. A deployment picks up RFC + auto-linking the moment it deploys this version. (RFCs only link once + they are in the `active` state — proposed/super-draft and withdrawn + RFCs are never link targets, matching the §11.3 universal-public read + rule.) + ## 0.24.0 — 2026-05-28 **Minor — one new secret required before this version serves tag diff --git a/VERSION b/VERSION index 2094a10..4e8f395 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.24.0 +0.26.0 diff --git a/backend/app/api_discussion.py b/backend/app/api_discussion.py index 9e8a000..8153096 100644 --- a/backend/app/api_discussion.py +++ b/backend/app/api_discussion.py @@ -40,7 +40,7 @@ from typing import Any from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel, Field -from . import auth, chat as chat_layer, db +from . import auth, chat as chat_layer, db, rfc_links log = logging.getLogger(__name__) @@ -171,9 +171,17 @@ def make_router() -> APIRouter: """, (thread_id,), ).fetchall() + # Roadmap #28 Part 1: enrich each discussion comment with RFC + # auto-link segments scanned against the live accepted-RFC corpus + # (read-time; see rfc_links.py). exclude_slug suppresses self-links + # to this RFC inside its own discussion. + link_index = rfc_links.build_index(db.conn(), exclude_slug=slug) + messages = [_serialize_message(r) for r in rows] + for m in messages: + m["text_segments"] = link_index.segment(m["text"]) return { "thread": _serialize_thread(thread), - "messages": [_serialize_message(r) for r in rows], + "messages": messages, } # ------------------------------------------------------------------- diff --git a/backend/app/api_prs.py b/backend/app/api_prs.py index d15a7c0..21668dd 100644 --- a/backend/app/api_prs.py +++ b/backend/app/api_prs.py @@ -23,7 +23,7 @@ from typing import Any from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel, Field -from . import auth, cache, chat as chat_layer, db, entry as entry_mod, funder, models_resolver +from . import auth, cache, chat as chat_layer, db, entry as entry_mod, funder, models_resolver, rfc_links from .bot import Bot from .config import Config from .gitea import Gitea, GiteaError @@ -213,6 +213,12 @@ def make_router( path = _file_path_for(rfc) head_branch = pr_row["head_branch"] + # Roadmap #28 Part 1: build the RFC auto-link index once for this + # PR view (read-time enrichment against the live accepted-RFC + # corpus; see rfc_links.py). exclude_slug suppresses self-links to + # this RFC inside its own PR. + link_index = rfc_links.build_index(db.conn(), exclude_slug=slug) + # §11.3: PRs are always public; no visibility check. main_fetched = await gitea.read_file(owner, repo, path, ref="main") main_body = _extract_body(rfc, (main_fetched or ("", ""))[0]) @@ -259,6 +265,13 @@ def make_router( for r in msg_rows: messages_by_thread.setdefault(r["thread_id"], []).append(_serialize_message(r)) + # Roadmap #28 Part 1: enrich every comment with RFC auto-link + # segments (read-time; see rfc_links.py). The description is + # enriched alongside it in the return dict below. + for _msgs in messages_by_thread.values(): + for _m in _msgs: + _m["text_segments"] = link_index.segment(_m["text"]) + # Per-user seen cursor per §10.3. Anonymous viewers get no # cursor — they always see "everything new" but cannot advance # the cursor (no row to write to). @@ -325,6 +338,7 @@ def make_router( "pr_number": pr_number, "title": pr_row["title"], "description": pr_row["description"], + "description_segments": link_index.segment(pr_row["description"]), "proposed_use_case": _pr_use_case(pr_number), "state": pr_row["state"], "opened_by": pr_row["opened_by"], diff --git a/backend/app/rfc_links.py b/backend/app/rfc_links.py new file mode 100644 index 0000000..582957f --- /dev/null +++ b/backend/app/rfc_links.py @@ -0,0 +1,158 @@ +"""Roadmap #28 Part 1 — auto-link RFC references in submitted prose. + +Scans plain-text PR descriptions and comment bodies for references to +existing **accepted** (state='active') RFCs and returns a structured 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. + +**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: + + 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. + +**Matching is conservative by design.** Only references that are unlikely +to be coincidental link: + + * ``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. +""" +from __future__ import annotations + +from typing import Any, Iterable + + +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 segment_text(text: str | None, terms: list[tuple[str, str, str]]) -> list[dict[str, Any]]: + """Split ``text`` into text / rfc-link segments against ``terms``. + + ``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. + + Always returns at least one segment; for empty/None input that is a + single empty text segment, so callers can render uniformly. + """ + 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[str, str, str, int] | None = None + for key, slug, title in terms: + klen = len(key) + if klen == 0 or not low.startswith(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) + break + if match is not None: + _key, slug, title, 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, + }) + 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 active 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() + + +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 __bool__(self) -> bool: + return bool(self._terms) + + def segment(self, text: str | None) -> list[dict[str, Any]]: + 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. + + ``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]] = [] + seen: set[str] = set() + for r in rows: + slug = r["slug"] + 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: + continue + seen.add(key) + terms.append((key, slug, title)) + return LinkIndex(terms) diff --git a/backend/tests/test_rfc_links_vertical.py b/backend/tests/test_rfc_links_vertical.py new file mode 100644 index 0000000..304aacc --- /dev/null +++ b/backend/tests/test_rfc_links_vertical.py @@ -0,0 +1,235 @@ +"""Roadmap #28 Part 1 — auto-link RFC references in PR text + comments. + +Two layers: + + * Unit tests over the pure scanner (`rfc_links.segment_text` / + `_keys_for` / `LinkIndex`) — the matching rules and their + false-positive guards, no DB. + * End-to-end tests that the PR description, PR review comments, and + PR-less discussion comments all surface `*_segments` enriched against + the live accepted-RFC corpus, with self-references suppressed. + +Reuses the FakeGitea + session helpers from test_propose_vertical.py and +the active-RFC seed from test_rfc_view_vertical.py. +""" +from __future__ import annotations + +from app import rfc_links + +from test_propose_vertical import ( # noqa: F401 + FakeGitea, + app_with_fake_gitea, + grant_rfc_collaborator, + provision_user_row, + sign_in_as, + tmp_env, +) +from test_rfc_view_vertical import SEED_BODY, seed_active_rfc +from test_pr_flow_vertical import _cut_branch_and_accept_change + + +# --------------------------------------------------------------------------- +# Unit — the pure scanner +# --------------------------------------------------------------------------- + + +def _idx(*terms): + """Build a LinkIndex from raw (key, slug, title) tuples (keys lower).""" + return rfc_links.LinkIndex(list(terms)) + + +def test_empty_text_is_single_empty_segment(): + assert rfc_links.segment_text("", []) == [{"type": "text", "text": ""}] + assert rfc_links.segment_text(None, []) == [{"type": "text", "text": ""}] + + +def test_no_terms_returns_plain_text(): + out = rfc_links.segment_text("hello world", []) + assert out == [{"type": "text", "text": "hello world"}] + + +def test_multiword_title_links_and_preserves_casing(): + idx = _idx(("open human model", "open-human-model", "Open Human Model")) + out = idx.segment("See the Open Human Model for details.") + assert out == [ + {"type": "text", "text": "See the "}, + {"type": "rfc", "slug": "open-human-model", "label": "Open Human Model", + "title": "Open Human Model"}, + {"type": "text", "text": " for details."}, + ] + + +def test_match_is_case_insensitive(): + idx = _idx(("open human model", "open-human-model", "Open Human Model")) + out = idx.segment("see the OPEN HUMAN MODEL") + assert out[-1] == {"type": "rfc", "slug": "open-human-model", + "label": "OPEN HUMAN MODEL", "title": "Open Human Model"} + + +def test_word_boundary_prevents_substring_match(): + # "harm" must not match inside "charming" / "harmless". + idx = _idx(("rfc-0001", "open-human-model", "Open Human Model")) + out = idx.segment("a charming rfc-00012 not real") + # rfc-0001 is a prefix of rfc-00012 but the trailing '2' is a word char, + # so no match — the whole string stays plain text. + assert out == [{"type": "text", "text": "a charming rfc-00012 not real"}] + + +def test_rfc_id_token_links(): + idx = _idx(("rfc-0001", "open-human-model", "Open Human Model")) + out = idx.segment("as established in RFC-0001.") + assert out[1] == {"type": "rfc", "slug": "open-human-model", + "label": "RFC-0001", "title": "Open Human Model"} + + +def test_longest_match_wins(): + # A bare "Open" term and the full title both present; the full title + # (longer) must win at the position. + idx = _idx( + ("open", "open", "Open"), + ("open human model", "open-human-model", "Open Human Model"), + ) + out = idx.segment("the Open Human Model") + assert out[-1]["slug"] == "open-human-model" + assert out[-1]["label"] == "Open Human Model" + + +def test_keys_for_gating(): + keys = lambda **kw: set(rfc_links._keys_for(**kw)) + # rfc_id always contributes. + assert "rfc-0001" in keys(slug="x", title="X", rfc_id="RFC-0001") + # multi-word title contributes; single common word does NOT. + assert "open human model" in keys(slug="ohm", title="Open Human Model", rfc_id=None) + assert keys(slug="human", title="Human", rfc_id=None) == set() + # hyphenated slug contributes; single-token slug does NOT. + assert "open-human-model" in keys(slug="open-human-model", title="X", rfc_id=None) + assert "ohm" not in keys(slug="ohm", title="OHM", rfc_id=None) + + +# --------------------------------------------------------------------------- +# End-to-end — enrichment surfaces on the read paths +# --------------------------------------------------------------------------- + + +def _open_pr_on(client, fake, *, host_slug: str, description: str): + """Seed branch + accepted change on host_slug and open a PR. Returns + the pr_number.""" + # `original` must exist verbatim in SEED_BODY or the accept is "stale". + branch, _ = _cut_branch_and_accept_change( + client, fake, slug=host_slug, + original="It defines consent, trait, and agency in compatible terms.", + proposed="It defines consent, trait, harm, and agency in compatible terms.", + ) + r = client.post( + f"/api/rfcs/{host_slug}/branches/{branch}/open-pr", + json={"title": "A change", "description": description}, + ) + assert r.status_code == 200, r.text + return r.json()["pr_number"] + + +def _rfc_segments(segments): + return [s for s in segments if s["type"] == "rfc"] + + +def test_pr_description_autolinks_other_rfc(app_with_fake_gitea): + from fastapi.testclient import TestClient + + app, fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=2, login="alice", role="contributor") + # Two accepted RFCs: a host for the PR + a referenceable target. + seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY) + seed_active_rfc(fake, slug="open-human-model", title="Open Human Model", body=SEED_BODY) + 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 definition.", + ) + r = client.get(f"/api/rfcs/ohm/prs/{pr_number}") + assert r.status_code == 200, r.text + pr = r.json() + links = _rfc_segments(pr["description_segments"]) + assert len(links) == 1 + assert links[0]["slug"] == "open-human-model" + assert links[0]["label"] == "Open Human Model" + # The plain text is still present for non-segment callers (the bot + # appends a §6.5 On-behalf-of trailer, so this is a containment check). + assert "This builds on the Open Human Model definition." in pr["description"] + + +def test_pr_review_comment_autolinked(app_with_fake_gitea): + from fastapi.testclient import TestClient + + app, fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=2, login="alice", role="contributor") + seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY) + seed_active_rfc(fake, slug="open-human-model", title="Open Human Model", body=SEED_BODY) + 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="plain.") + r = client.post( + f"/api/rfcs/ohm/prs/{pr_number}/review", + json={"text": "See Open Human Model and RFC-0001.", "anchor_payload": {}}, + ) + assert r.status_code == 200, r.text + + pr = client.get(f"/api/rfcs/ohm/prs/{pr_number}").json() + all_msgs = [m for msgs in pr["messages_by_thread"].values() for m in msgs] + review_msgs = [m for m in all_msgs if "Open Human Model" in (m["text"] or "")] + assert review_msgs, "review comment not found in payload" + links = _rfc_segments(review_msgs[0]["text_segments"]) + # Both "Open Human Model" (title) and "RFC-0001" (id) point to the + # one referenceable RFC. + assert {s["slug"] for s in links} == {"open-human-model"} + assert {s["label"] for s in links} == {"Open Human Model", "RFC-0001"} + + +def test_discussion_comment_autolinked(app_with_fake_gitea): + from fastapi.testclient import TestClient + + app, fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=2, login="alice", role="contributor") + seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY) + seed_active_rfc(fake, slug="open-human-model", title="Open Human Model", body=SEED_BODY) + # alice is the seeded owner of ohm (owners=["alice"]); grant the + # per-RFC collaborator row explicitly so the #12 discuss gate passes. + grant_rfc_collaborator(user_id=2, rfc_slug="ohm", role_in_rfc="contributor") + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") + + r = client.post( + "/api/rfcs/ohm/discussion/threads", + json={"message": "Compare with the Open Human Model."}, + ) + assert r.status_code == 200, r.text + thread_id = r.json()["thread_id"] + + r = client.get(f"/api/rfcs/ohm/discussion/threads/{thread_id}/messages") + assert r.status_code == 200, r.text + msgs = r.json()["messages"] + assert msgs and "text_segments" in msgs[0] + links = _rfc_segments(msgs[0]["text_segments"]) + assert len(links) == 1 + assert links[0]["slug"] == "open-human-model" + + +def test_self_reference_not_linked(app_with_fake_gitea): + from fastapi.testclient import TestClient + + app, fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=2, login="alice", role="contributor") + # The host RFC has a multi-word title, so absent exclude_slug it + # WOULD self-link. exclude_slug must suppress it. + seed_active_rfc(fake, slug="open-human-model", title="Open Human Model", body=SEED_BODY) + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") + + pr_number = _open_pr_on( + client, fake, host_slug="open-human-model", + description="Refines the Open Human Model definition.", + ) + pr = client.get(f"/api/rfcs/open-human-model/prs/{pr_number}").json() + assert _rfc_segments(pr["description_segments"]) == [] diff --git a/frontend/package.json b/frontend/package.json index b0116e9..51ea794 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "rfc-app-frontend", "private": true, - "version": "0.24.0", + "version": "0.26.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.css b/frontend/src/App.css index 37fcd47..8109b9d 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1354,6 +1354,17 @@ .pr-breadcrumb a:hover { text-decoration: underline; } .pr-title { font-size: var(--text-xl); margin: 0 0 6px 0; } .pr-description { font-size: var(--text-base); color: var(--c-gray-600); margin: 0 0 6px 0; line-height: 1.55; } +/* Roadmap #28 Part 1: inline auto-links to referenced RFCs inside PR text + and comments. Subtle accent + dotted underline so they read as enriched + references, not as primary navigation. */ +.rfc-autolink { + color: var(--color-link); + text-decoration: underline; + text-decoration-style: dotted; + text-underline-offset: 2px; + font-weight: 500; +} +.rfc-autolink:hover { text-decoration-style: solid; } .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/components/LinkedText.jsx b/frontend/src/components/LinkedText.jsx new file mode 100644 index 0000000..6566ff0 --- /dev/null +++ b/frontend/src/components/LinkedText.jsx @@ -0,0 +1,38 @@ +// LinkedText.jsx — roadmap #28 Part 1. +// +// Renders a backend-provided list of text/rfc-link segments (see +// backend/app/rfc_links.py). RFC references in PR descriptions and +// comments arrive pre-scanned as structured segments — this component +// maps them onto plain text runs and anchor elements. It never renders +// HTML from the server (no dangerouslySetInnerHTML), so the surface is +// XSS-safe regardless of what a comment author typed. +// +// `segments` is the enriched array; `text` is the raw fallback used when +// the field is absent (an older cached response, or a caller that didn't +// pass segments). Either way the visible text is identical — only the +// links differ. + +export default function LinkedText({ segments, text }) { + if (!Array.isArray(segments) || segments.length === 0) { + return <>{text ?? ''} + } + return ( + <> + {segments.map((seg, i) => { + if (seg.type === 'rfc') { + return ( + + {seg.label} + + ) + } + return {seg.text} + })} + + ) +} diff --git a/frontend/src/components/PRView.jsx b/frontend/src/components/PRView.jsx index 7cbc25a..455db78 100644 --- a/frontend/src/components/PRView.jsx +++ b/frontend/src/components/PRView.jsx @@ -23,6 +23,7 @@ import { withdrawPR, } from '../api' import { EVENTS, track } from '../lib/analytics' +import LinkedText from './LinkedText' export default function PRView({ viewer }) { const { slug, prNumber: prNumberParam } = useParams() @@ -218,7 +219,9 @@ export default function PRView({ viewer }) { <>

{pr.title}

{pr.description && ( -

{pr.description}

+

+ +

)} {/* #26: the optional ground-truth use case for this change, captured when the PR was opened. Muted "left blank" @@ -440,7 +443,9 @@ function PRConversation({ threads, messagesByThread, threadsByKind, seenMsgId }) {isNew && } {m.quote &&
{m.quote}
} -
{m.text}
+
+ +
) })} diff --git a/frontend/src/components/RFCDiscussionPanel.jsx b/frontend/src/components/RFCDiscussionPanel.jsx index 197f293..a322a26 100644 --- a/frontend/src/components/RFCDiscussionPanel.jsx +++ b/frontend/src/components/RFCDiscussionPanel.jsx @@ -19,6 +19,7 @@ import { resolveDiscussionThread, } from '../api' import { EVENTS, track } from '../lib/analytics' +import LinkedText from './LinkedText' export default function RFCDiscussionPanel({ slug, viewer }) { const [threads, setThreads] = useState([]) @@ -279,7 +280,9 @@ function DiscussionMessage({ message }) { {message.quote && (
"{message.quote}"
)} -
{message.text}
+
+ +
) }