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,163 @@
|
||||
// ContributeRequestForm.jsx — roadmap #28 Part 3.
|
||||
//
|
||||
// The "ask to contribute" popover, opened from an `rfc-pending` affordance
|
||||
// in LinkedText (App reads `?contribute=<slug>&term=<term>`). It loads the
|
||||
// contribution target (RFC title + owner display + the viewer's
|
||||
// eligibility), shows the framing line "<owner> is working on an RFC for
|
||||
// '<term>'", and collects the three #15/#26-vocabulary fields:
|
||||
//
|
||||
// * Who I am (required, free-text)
|
||||
// * Why I'm asking (required, free-text)
|
||||
// * What I'd use it for (optional, mirrors #26)
|
||||
//
|
||||
// Submitting POSTs the request, which lands in each owner's §15 inbox.
|
||||
// When the viewer isn't eligible (anonymous, already a collaborator, or
|
||||
// has a pending ask) the form shows the backend's reason instead.
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { contributionTarget, requestContribution } from '../api'
|
||||
|
||||
export default function ContributeRequestForm({ slug, term, onClose }) {
|
||||
const [target, setTarget] = useState(null)
|
||||
const [loadError, setLoadError] = useState(null)
|
||||
const [whoIAm, setWhoIAm] = useState('')
|
||||
const [why, setWhy] = useState('')
|
||||
const [useCase, setUseCase] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [done, setDone] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let live = true
|
||||
contributionTarget(slug)
|
||||
.then(t => { if (live) setTarget(t) })
|
||||
.catch(err => { if (live) setLoadError(err.message || 'Could not load this RFC.') })
|
||||
return () => { live = false }
|
||||
}, [slug])
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault()
|
||||
if (!whoIAm.trim() || !why.trim()) return
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
await requestContribution(slug, {
|
||||
matchedTerm: term || target?.title || slug,
|
||||
whoIAm: whoIAm.trim(),
|
||||
why: why.trim(),
|
||||
useCase: useCase.trim() || null,
|
||||
})
|
||||
setDone(true)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Could not send your request.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const owner = target?.owner || 'The owner'
|
||||
const label = term || target?.title || slug
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<div className="modal">
|
||||
<div className="modal-header">
|
||||
<h2>Ask to contribute</h2>
|
||||
<button className="modal-close" onClick={onClose}>×</button>
|
||||
</div>
|
||||
|
||||
{loadError && (
|
||||
<div className="modal-body"><p className="field-error">{loadError}</p></div>
|
||||
)}
|
||||
|
||||
{!loadError && done && (
|
||||
<>
|
||||
<div className="modal-body">
|
||||
<p>
|
||||
Your request has been sent to <strong>{owner}</strong>. You'll hear back
|
||||
in your inbox; if it's accepted you'll get an invitation by email to
|
||||
join the RFC.
|
||||
</p>
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn-primary" onClick={onClose}>Done</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!loadError && !done && target && !target.eligible && (
|
||||
<>
|
||||
<div className="modal-body">
|
||||
<p className="field-help" style={{ marginTop: 0 }}>
|
||||
{owner} is working on an RFC for <strong>'{label}'</strong>.
|
||||
</p>
|
||||
<p>{target.already_requested
|
||||
? "You've already asked to contribute to this RFC — the owner has your request."
|
||||
: (target.reason || 'You cannot ask to contribute to this RFC right now.')}</p>
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn-secondary" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!loadError && !done && target && target.eligible && (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="modal-body">
|
||||
<p className="field-help" style={{ marginTop: 0 }}>
|
||||
<strong>{owner}</strong> is working on an RFC for <strong>'{label}'</strong>.
|
||||
Tell them a little about why you'd like to contribute.
|
||||
</p>
|
||||
|
||||
<label htmlFor="contribute-who">Who I am</label>
|
||||
<textarea
|
||||
id="contribute-who"
|
||||
value={whoIAm}
|
||||
onChange={e => setWhoIAm(e.target.value)}
|
||||
placeholder="Your name and a sentence of context."
|
||||
rows={2}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
|
||||
<label htmlFor="contribute-why">Why I'm asking to contribute</label>
|
||||
<textarea
|
||||
id="contribute-why"
|
||||
value={why}
|
||||
onChange={e => setWhy(e.target.value)}
|
||||
placeholder="What you'd bring, or what draws you to this RFC."
|
||||
rows={3}
|
||||
required
|
||||
/>
|
||||
|
||||
<label htmlFor="contribute-use-case">What I'd use the RFC for (optional)</label>
|
||||
<textarea
|
||||
id="contribute-use-case"
|
||||
value={useCase}
|
||||
onChange={e => setUseCase(e.target.value)}
|
||||
placeholder="The concrete thing you intend to build or do with it. Optional."
|
||||
rows={2}
|
||||
/>
|
||||
|
||||
{error && <p className="field-error">{error}</p>}
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn-secondary" onClick={onClose}>Cancel</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={!whoIAm.trim() || !why.trim() || submitting}
|
||||
>
|
||||
{submitting ? 'Sending…' : 'Send request'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{!loadError && !done && !target && (
|
||||
<div className="modal-body"><p className="field-help">Loading…</p></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -126,3 +126,13 @@
|
||||
line-height: var(--leading-normal);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* #28 Part 3 — actionable contribute-request row: the requester's
|
||||
who/why/use-case detail plus an Accept/Decline pair. */
|
||||
.inbox-row-action { display: flex; flex-direction: column; gap: 8px; padding: 12px; }
|
||||
.inbox-row-action .inbox-row-main { display: flex; align-items: center; gap: 8px; }
|
||||
.inbox-request-detail { margin-left: 18px; font-size: var(--text-sm); }
|
||||
.inbox-request-detail p { margin: 2px 0; color: var(--color-text-muted); }
|
||||
.inbox-request-detail strong { color: var(--color-text); }
|
||||
.inbox-request-actions { display: flex; gap: 8px; margin-left: 18px; }
|
||||
.inbox-request-outcome { margin: 0 0 0 18px; }
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
acceptContributionRequest,
|
||||
declineContributionRequest,
|
||||
listNotifications,
|
||||
markNotificationRead,
|
||||
markNotificationsReadByFilter,
|
||||
@@ -164,7 +166,71 @@ export default function Inbox({ onClose, lastChangeTick }) {
|
||||
)
|
||||
}
|
||||
|
||||
// #28 Part 3: the contribute-request row is the first actionable inbox
|
||||
// kind — it renders the requester's who/why/use-case inline and an
|
||||
// Accept/Decline pair that fire the owner's decision (accept reuses #12's
|
||||
// invite flow on the backend).
|
||||
function ContributionRequestRow({ item, onMarkRead }) {
|
||||
const unread = !item.read_at
|
||||
const x = item.extras || {}
|
||||
const [outcome, setOutcome] = useState(null) // 'accepted' | 'declined'
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
async function act(accept) {
|
||||
if (busy || outcome) return
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
if (accept) await acceptContributionRequest(item.rfc_slug, x.request_id)
|
||||
else await declineContributionRequest(item.rfc_slug, x.request_id)
|
||||
setOutcome(accept ? 'accepted' : 'declined')
|
||||
await onMarkRead(item)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Action failed.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<li className={`inbox-row inbox-row-action ${unread ? 'unread' : 'read'}`}>
|
||||
<div className="inbox-row-main">
|
||||
<span className="inbox-unread-dot" aria-hidden />
|
||||
<span className={`inbox-cat cat-${item.category || 'unknown'}`}>{item.category || '·'}</span>
|
||||
<span className="inbox-summary">{item.summary}</span>
|
||||
<span className="inbox-when">{formatWhen(item.created_at)}</span>
|
||||
</div>
|
||||
<div className="inbox-request-detail">
|
||||
{x.who_i_am && <p><strong>Who:</strong> {x.who_i_am}</p>}
|
||||
{x.why && <p><strong>Why:</strong> {x.why}</p>}
|
||||
{x.use_case && <p><strong>Use case:</strong> {x.use_case}</p>}
|
||||
</div>
|
||||
{error && <p className="field-error">{error}</p>}
|
||||
{outcome ? (
|
||||
<p className="inbox-request-outcome muted">
|
||||
{outcome === 'accepted'
|
||||
? 'Accepted — an invitation has been sent.'
|
||||
: 'Declined.'}
|
||||
</p>
|
||||
) : (
|
||||
<div className="inbox-request-actions">
|
||||
<button type="button" className="btn-primary" disabled={busy || !x.request_id} onClick={() => act(true)}>
|
||||
Accept
|
||||
</button>
|
||||
<button type="button" className="btn-secondary" disabled={busy || !x.request_id} onClick={() => act(false)}>
|
||||
Decline
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function InboxRow({ item, onClick, onMarkRead, onClose }) {
|
||||
if (item.event_kind === 'contribution_request_on_pending_rfc') {
|
||||
return <ContributionRequestRow item={item} onMarkRead={onMarkRead} />
|
||||
}
|
||||
const unread = !item.read_at
|
||||
const target = deepLink(item)
|
||||
const handle = async () => {
|
||||
|
||||
@@ -1,21 +1,40 @@
|
||||
// LinkedText.jsx — roadmap #28 Part 1.
|
||||
// LinkedText.jsx — roadmap #28 (Parts 1–3).
|
||||
//
|
||||
// Renders a backend-provided list of text/rfc-link segments (see
|
||||
// backend/app/rfc_links.py). RFC references in PR descriptions and
|
||||
// comments arrive pre-scanned as structured segments — this component
|
||||
// maps them onto plain text runs and anchor elements. It never renders
|
||||
// Renders a backend-provided list of text/link segments (see
|
||||
// backend/app/rfc_links.py). References in PR descriptions and comments
|
||||
// arrive pre-scanned as structured segments — this component maps them
|
||||
// onto plain text runs, anchors, and inline affordances. It never renders
|
||||
// HTML from the server (no dangerouslySetInnerHTML), so the surface is
|
||||
// XSS-safe regardless of what a comment author typed.
|
||||
//
|
||||
// Segment types:
|
||||
// * `rfc` — Part 1: a link to an accepted (active) RFC.
|
||||
// * `rfc-pending` — Part 3: the term names a pending (super-draft)
|
||||
// RFC; a signed-in viewer who isn't its owner gets
|
||||
// an inline "ask to contribute" affordance routing
|
||||
// to the contribute form (App reads `?contribute=`).
|
||||
// * `rfc-candidate` — Part 2: a strong-candidate term with no RFC yet;
|
||||
// a viewer with create rights (`canCreate`) gets a
|
||||
// "create RFC" affordance routing to the propose
|
||||
// flow pre-filled (App reads `?propose=`).
|
||||
//
|
||||
// Affordances degrade to plain text when the viewer lacks the relevant
|
||||
// right, so the visible prose is identical for everyone — only the
|
||||
// offered actions differ.
|
||||
//
|
||||
// `segments` is the enriched array; `text` is the raw fallback used when
|
||||
// the field is absent (an older cached response, or a caller that didn't
|
||||
// pass segments). Either way the visible text is identical — only the
|
||||
// links differ.
|
||||
// pass segments).
|
||||
|
||||
export default function LinkedText({ segments, text }) {
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
export default function LinkedText({ segments, text, viewer, canCreate }) {
|
||||
if (!Array.isArray(segments) || segments.length === 0) {
|
||||
return <>{text ?? ''}</>
|
||||
}
|
||||
// A signed-in, beta-granted viewer can ask to contribute; the backend
|
||||
// re-checks ownership/collaborator status and rejects self-requests.
|
||||
const canContribute = !!viewer && viewer.permission_state === 'granted'
|
||||
return (
|
||||
<>
|
||||
{segments.map((seg, i) => {
|
||||
@@ -31,6 +50,40 @@ export default function LinkedText({ segments, text }) {
|
||||
</a>
|
||||
)
|
||||
}
|
||||
if (seg.type === 'rfc-pending') {
|
||||
const who = seg.owner || 'Someone'
|
||||
return (
|
||||
<span key={i} className="rfc-pending">
|
||||
{seg.label}
|
||||
{canContribute && (
|
||||
<Link
|
||||
className="rfc-offer rfc-offer-contribute"
|
||||
to={`?contribute=${encodeURIComponent(seg.slug)}&term=${encodeURIComponent(seg.label)}`}
|
||||
title={`${who} is working on an RFC for '${seg.label}' — ask to contribute`}
|
||||
>
|
||||
ask to contribute
|
||||
</Link>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (seg.type === 'rfc-candidate') {
|
||||
const term = seg.term || seg.label
|
||||
return (
|
||||
<span key={i} className="rfc-candidate">
|
||||
{seg.label}
|
||||
{canCreate && (
|
||||
<Link
|
||||
className="rfc-offer rfc-offer-create"
|
||||
to={`?propose=${encodeURIComponent(term)}`}
|
||||
title={`Create RFC for '${term}'`}
|
||||
>
|
||||
+ create RFC
|
||||
</Link>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return <span key={i}>{seg.text}</span>
|
||||
})}
|
||||
</>
|
||||
|
||||
@@ -220,7 +220,7 @@ export default function PRView({ viewer }) {
|
||||
<h1 className="pr-title">{pr.title}</h1>
|
||||
{pr.description && (
|
||||
<p className="pr-description">
|
||||
<LinkedText segments={pr.description_segments} text={pr.description} />
|
||||
<LinkedText segments={pr.description_segments} text={pr.description} viewer={viewer} canCreate={viewer?.permission_state === 'granted'} />
|
||||
</p>
|
||||
)}
|
||||
{/* #26: the optional ground-truth use case for this change,
|
||||
@@ -444,7 +444,7 @@ function PRConversation({ threads, messagesByThread, threadsByKind, seenMsgId })
|
||||
</div>
|
||||
{m.quote && <pre className="chat-msg-quote">{m.quote}</pre>}
|
||||
<div className="chat-msg-body">
|
||||
<LinkedText segments={m.text_segments} text={m.text} />
|
||||
<LinkedText segments={m.text_segments} text={m.text} viewer={viewer} canCreate={viewer?.permission_state === 'granted'} />
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
|
||||
@@ -28,8 +28,11 @@ function slugify(title) {
|
||||
.replace(/^-+|-+$/g, '')
|
||||
}
|
||||
|
||||
export default function ProposeModal({ viewer, onClose, onSubmitted }) {
|
||||
const [title, setTitle] = useState('')
|
||||
export default function ProposeModal({ viewer, onClose, onSubmitted, initialTitle = '' }) {
|
||||
// #28 Part 2: a "create RFC for '<term>'" affordance pre-fills the title
|
||||
// (App passes the `?propose=<term>` value here); the slug derives from it
|
||||
// via the same effect that drives manual typing.
|
||||
const [title, setTitle] = useState(initialTitle)
|
||||
const [slug, setSlug] = useState('')
|
||||
const [slugEdited, setSlugEdited] = useState(false)
|
||||
const [pitch, setPitch] = useState('')
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user