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:
@@ -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,
|
||||
}
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
+15
-1
@@ -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"],
|
||||
|
||||
@@ -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)
|
||||
@@ -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"]) == []
|
||||
Reference in New Issue
Block a user