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>
274 lines
12 KiB
Python
274 lines
12 KiB
Python
"""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_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.
|
|
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"]) == []
|