v0.29.0: #28 Parts 2+3 — create-RFC offers + contribute-to-pending requests
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>
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
"""v0.29.0 / roadmap #28 Parts 2 & 3 — create-RFC offers + contribute-to-
|
||||
pending requests.
|
||||
|
||||
Two layers, mirroring test_rfc_links_vertical.py:
|
||||
|
||||
* The PR-view scanner surfaces `rfc-pending` (Part 3) and `rfc-candidate`
|
||||
(Part 2) segments alongside Part 1's `rfc` links.
|
||||
* The contribute-request flow end-to-end: a non-owner asks, each owner
|
||||
gets an actionable §15 notification, accept fires #12's invite flow,
|
||||
decline notifies the requester.
|
||||
|
||||
Reuses the FakeGitea + seed/session helpers from the existing suites.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import db
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
FakeGitea,
|
||||
app_with_fake_gitea,
|
||||
provision_user_row,
|
||||
sign_in_as,
|
||||
tmp_env,
|
||||
)
|
||||
from test_rfc_view_vertical import SEED_BODY, seed_active_rfc
|
||||
from test_super_draft_vertical import seed_super_draft
|
||||
from test_rfc_links_vertical import _open_pr_on
|
||||
|
||||
|
||||
def _set_owner(slug: str, login: str) -> None:
|
||||
db.conn().execute(
|
||||
"UPDATE cached_rfcs SET owners_json = ? WHERE slug = ?",
|
||||
(json.dumps([login]), slug),
|
||||
)
|
||||
|
||||
|
||||
def _set_tags(slug: str, tags: list[str]) -> None:
|
||||
db.conn().execute(
|
||||
"UPDATE cached_rfcs SET tags_json = ? WHERE slug = ?",
|
||||
(json.dumps(tags), slug),
|
||||
)
|
||||
|
||||
|
||||
def _segs(segments, kind):
|
||||
return [s for s in segments if s["type"] == kind]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Part 2 + Part 3 — scanner surfaces on the PR view
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_pending_and_candidate_segments_on_pr(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
# Host active RFC (OHM — single word, contributes no keys itself)
|
||||
# carrying a multi-word tag with no defining RFC: the Part 2
|
||||
# candidate. And a pending super-draft owned by alice: the Part 3
|
||||
# contribute target.
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
_set_tags("ohm", ["memory model", "identity"])
|
||||
seed_super_draft(fake, slug="open-human-model", title="Open Human Model",
|
||||
pitch="A framework for representing humans.", proposed_by="alice")
|
||||
_set_owner("open-human-model", "alice")
|
||||
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 and the memory model.",
|
||||
)
|
||||
pr = client.get(f"/api/rfcs/ohm/prs/{pr_number}").json()
|
||||
segs = pr["description_segments"]
|
||||
|
||||
pending = _segs(segs, "rfc-pending")
|
||||
assert len(pending) == 1
|
||||
assert pending[0]["slug"] == "open-human-model"
|
||||
assert pending[0]["label"] == "Open Human Model"
|
||||
assert pending[0]["owner"] == "Alice" # display_name of the owner
|
||||
|
||||
candidate = _segs(segs, "rfc-candidate")
|
||||
assert len(candidate) == 1
|
||||
assert candidate[0]["term"] == "memory model"
|
||||
# "identity" is a single-word tag — deliberately NOT a candidate.
|
||||
assert all("identity" not in s.get("term", "") for s in candidate)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Part 3 — the contribute-request flow
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _seed_pending_owned_by_alice(fake):
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
provision_user_row(user_id=3, login="bob", role="contributor")
|
||||
seed_super_draft(fake, slug="open-human-model", title="Open Human Model",
|
||||
pitch="A framework.", proposed_by="alice")
|
||||
_set_owner("open-human-model", "alice")
|
||||
|
||||
|
||||
_REQUEST = {
|
||||
"matched_term": "Open Human Model",
|
||||
"who_i_am": "Bob, a researcher",
|
||||
"why": "I have relevant prior work to bring.",
|
||||
"use_case": "Building an identity tool.",
|
||||
}
|
||||
|
||||
|
||||
def test_request_accept_invites_and_notifies(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_pending_owned_by_alice(fake)
|
||||
|
||||
# Bob asks to contribute.
|
||||
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob",
|
||||
role="contributor", email="bob@test")
|
||||
r = client.post("/api/rfcs/open-human-model/contribution-requests", json=_REQUEST)
|
||||
assert r.status_code == 200, r.text
|
||||
request_id = r.json()["id"]
|
||||
assert r.json()["status"] == "pending"
|
||||
|
||||
# A second ask while pending is a 409, not a duplicate row.
|
||||
assert client.post("/api/rfcs/open-human-model/contribution-requests",
|
||||
json=_REQUEST).status_code == 409
|
||||
|
||||
# Alice (owner) sees the actionable notification with full detail.
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice",
|
||||
role="contributor", email="alice@test")
|
||||
inbox = client.get("/api/notifications").json()
|
||||
reqs = [i for i in inbox["items"]
|
||||
if i["event_kind"] == "contribution_request_on_pending_rfc"]
|
||||
assert len(reqs) == 1
|
||||
assert "wants to contribute" in reqs[0]["summary"]
|
||||
assert reqs[0]["extras"]["who_i_am"] == "Bob, a researcher"
|
||||
assert reqs[0]["extras"]["request_id"] == request_id
|
||||
|
||||
# Alice accepts → #12 invitation minted for bob's email.
|
||||
acc = client.post(f"/api/rfcs/open-human-model/contribution-requests/{request_id}/accept")
|
||||
assert acc.status_code == 200, acc.text
|
||||
assert acc.json()["status"] == "accepted"
|
||||
assert acc.json()["invitation_id"]
|
||||
|
||||
inv = db.conn().execute(
|
||||
"SELECT invitee_email, role_in_rfc, status FROM rfc_invitations "
|
||||
"WHERE rfc_slug = 'open-human-model'"
|
||||
).fetchone()
|
||||
assert inv["invitee_email"] == "bob@test"
|
||||
assert inv["role_in_rfc"] == "contributor"
|
||||
assert inv["status"] == "pending"
|
||||
|
||||
# The request is settled — re-accepting is a 409.
|
||||
assert client.post(
|
||||
f"/api/rfcs/open-human-model/contribution-requests/{request_id}/accept"
|
||||
).status_code == 409
|
||||
|
||||
# Bob gets the accepted echo in his inbox.
|
||||
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob",
|
||||
role="contributor", email="bob@test")
|
||||
bob_kinds = [i["event_kind"] for i in client.get("/api/notifications").json()["items"]]
|
||||
assert "contribution_request_accepted" in bob_kinds
|
||||
|
||||
|
||||
def test_decline_notifies_requester(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_pending_owned_by_alice(fake)
|
||||
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob",
|
||||
role="contributor", email="bob@test")
|
||||
request_id = client.post(
|
||||
"/api/rfcs/open-human-model/contribution-requests", json=_REQUEST
|
||||
).json()["id"]
|
||||
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice",
|
||||
role="contributor", email="alice@test")
|
||||
dec = client.post(f"/api/rfcs/open-human-model/contribution-requests/{request_id}/decline")
|
||||
assert dec.status_code == 200, dec.text
|
||||
assert dec.json()["status"] == "declined"
|
||||
|
||||
row = db.conn().execute(
|
||||
"SELECT status FROM contribution_requests WHERE id = ?", (request_id,)
|
||||
).fetchone()
|
||||
assert row["status"] == "declined"
|
||||
|
||||
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob",
|
||||
role="contributor", email="bob@test")
|
||||
bob_kinds = [i["event_kind"] for i in client.get("/api/notifications").json()["items"]]
|
||||
assert "contribution_request_declined" in bob_kinds
|
||||
|
||||
|
||||
def test_owner_cannot_request_own_rfc(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_pending_owned_by_alice(fake)
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice",
|
||||
role="contributor", email="alice@test")
|
||||
r = client.post("/api/rfcs/open-human-model/contribution-requests", json=_REQUEST)
|
||||
assert r.status_code == 409
|
||||
assert "own" in r.json()["detail"].lower()
|
||||
|
||||
|
||||
def test_request_on_active_rfc_rejected(app_with_fake_gitea):
|
||||
# The contribute offer only exists for pending super-drafts; an active
|
||||
# RFC uses the Part-1 link instead.
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=3, login="bob", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob",
|
||||
role="contributor", email="bob@test")
|
||||
r = client.post("/api/rfcs/ohm/contribution-requests", json=_REQUEST)
|
||||
assert r.status_code == 409
|
||||
|
||||
|
||||
def test_contribution_target_eligibility(app_with_fake_gitea):
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_seed_pending_owned_by_alice(fake)
|
||||
|
||||
# Anonymous: not eligible, told to sign in.
|
||||
t = client.get("/api/rfcs/open-human-model/contribution-target").json()
|
||||
assert t["eligible"] is False
|
||||
assert "sign in" in (t["reason"] or "").lower()
|
||||
assert t["owner"] == "Alice"
|
||||
|
||||
# Bob: eligible until he has a pending ask, then not.
|
||||
sign_in_as(client, user_id=3, gitea_login="bob", display_name="Bob",
|
||||
role="contributor", email="bob@test")
|
||||
assert client.get("/api/rfcs/open-human-model/contribution-target").json()["eligible"] is True
|
||||
client.post("/api/rfcs/open-human-model/contribution-requests", json=_REQUEST)
|
||||
after = client.get("/api/rfcs/open-human-model/contribution-target").json()
|
||||
assert after["already_requested"] is True
|
||||
assert after["eligible"] is False
|
||||
@@ -94,6 +94,44 @@ def test_longest_match_wins():
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user