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:
Ben Stull
2026-05-28 20:10:13 -07:00
parent 019c8a9185
commit 3c9109c392
21 changed files with 1530 additions and 143 deletions
@@ -191,7 +191,7 @@ export default function RFCDiscussionPanel({ slug, viewer }) {
</div>
)}
{activeMessages.map(msg => (
<DiscussionMessage key={msg.id} message={msg} />
<DiscussionMessage key={msg.id} message={msg} viewer={viewer} />
))}
<div ref={bottomRef} />
</div>
@@ -258,7 +258,7 @@ export default function RFCDiscussionPanel({ slug, viewer }) {
)
}
function DiscussionMessage({ message }) {
function DiscussionMessage({ message, viewer }) {
const isSystem = message.role === 'system'
if (isSystem) {
return (
@@ -281,7 +281,7 @@ function DiscussionMessage({ message }) {
<div className="discussion-message-quote">"{message.quote}"</div>
)}
<div className="discussion-message-body">
<LinkedText segments={message.text_segments} text={message.text} />
<LinkedText segments={message.text_segments} text={message.text} viewer={viewer} canCreate={viewer?.permission_state === 'granted'} />
</div>
</div>
)