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>
237 lines
9.8 KiB
Python
237 lines
9.8 KiB
Python
"""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
|