v0.26.0: auto-link referenced RFCs in PR text + comments (roadmap #28 Part 1)

Part 1 of item #28: references to existing accepted RFCs inside PR
descriptions and comment text now render as inline links to the
referenced RFC. Parts 2 (offer-to-create) and 3 (offer-to-contribute-
to-pending) are deliberately deferred — Part 1 ships first as the easy
win, per the roadmap row.

Shipped in parallel with the v0.25.0 security-hardening session; this
took the next free version slot (0.26.0) per the roadmap's
"claims the next available version number" rule. Expect a top-of-file
CHANGELOG/VERSION merge with 0.25.0 — distinct concerns, trivial to
resolve.

Backend:
- rfc_links.py (new): builds a term index from the live accepted
  (state='active') RFC corpus and segments plain text into text /
  rfc-link segments. Conservative matching — links only rfc_id tokens
  (RFC-0001), multi-word titles (Open Human Model), and hyphenated
  slugs (open-human-model); a single common-word title/slug is NOT
  linked (would turn every prose "human" into a link). Case-insensitive,
  word-boundary-anchored, longest-match-wins, self-reference suppressed.
- api_prs.py get_pr(): enriches the PR description (description_segments)
  and every PR comment (text_segments).
- api_discussion.py: enriches PR-less discussion comments (text_segments).

Read-time, not submit-time: the roadmap says "at submit time" but the
intent it names is "not as live compose preview", which read-time
honors. Chosen for correctness (links track the live active set —
newly-accepted RFCs start linking, withdrawn ones stop), zero migration,
and cheapness (small cache-resident corpus). Recorded as a §19.3-rule-2
note in the session transcript.

Frontend:
- LinkedText.jsx (new): maps backend segments onto React text nodes +
  anchors. No dangerouslySetInnerHTML — XSS-safe by construction,
  independent of any HTML-sanitization layer. Falls back to raw text
  when segments are absent.
- PRView.jsx: description + PR conversation comment bodies render via
  LinkedText.
- RFCDiscussionPanel.jsx: discussion comment bodies render via LinkedText.
- App.css: .rfc-autolink (subtle accent + dotted underline, tokenized).

Tests: 12 new (test_rfc_links_vertical.py) — 9 scanner units + 3
end-to-end (PR description / review comment / discussion comment all
surface *_segments; self-reference suppression). Full suite 363 green;
frontend builds clean.

No upgrade steps: additive, no migration, no secret, no config.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-05-28 16:17:01 -07:00
parent 28015ed1a2
commit e794523079
11 changed files with 546 additions and 8 deletions
+38
View File
@@ -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 (
<a
key={i}
className="rfc-autolink"
href={`/rfc/${seg.slug}`}
title={seg.title ? `RFC: ${seg.title}` : undefined}
>
{seg.label}
</a>
)
}
return <span key={i}>{seg.text}</span>
})}
</>
)
}
+7 -2
View File
@@ -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 }) {
<>
<h1 className="pr-title">{pr.title}</h1>
{pr.description && (
<p className="pr-description">{pr.description}</p>
<p className="pr-description">
<LinkedText segments={pr.description_segments} text={pr.description} />
</p>
)}
{/* #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 && <span className="chat-msg-new-pip" title="New since your last visit">●</span>}
</div>
{m.quote && <pre className="chat-msg-quote">{m.quote}</pre>}
<div className="chat-msg-body">{m.text}</div>
<div className="chat-msg-body">
<LinkedText segments={m.text_segments} text={m.text} />
</div>
</li>
)
})}
@@ -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 && (
<div className="discussion-message-quote">"{message.quote}"</div>
)}
<div className="discussion-message-body">{message.text}</div>
<div className="discussion-message-body">
<LinkedText segments={message.text_segments} text={message.text} />
</div>
</div>
)
}