Release 0.16.0: owner-only invite for per-RFC contribution + discussion (+ #21 Part C Amplitude wiring)
Wave 5 / Track B. Roadmap item #12. Folds in #21 Part C Amplitude
wiring inline (operator ask: "best practices from the very get-go").
RFC owners can invite specific users to one of two per-RFC roles:
contributor (open PRs + join discussion) or discussant (discussion
only). Non-invited users keep the v0.6.0 anonymous-read contract.
The per-RFC write gate layers on top of the existing
require_contributor gate; a super-draft with no owners yet falls
through to the platform-granted contract, preserving the
v0.6.0/v0.7.0/v0.8.0 contracts in their domains.
Backend: migration 018_rfc_invitations.sql (auto-applied — two
tables: rfc_invitations + rfc_collaborators); api_invitations.py
with five endpoints + transactional email; auth.py helpers
(is_rfc_owner / is_rfc_collaborator / can_discuss_rfc /
can_contribute_to_rfc / can_invite_to_rfc); api_discussion +
api_branches + api_prs gate composition; api_admin.py additive
rfc_invitations[] per user. 237 backend tests pass (18 new in
test_rfc_invitations_vertical.py).
Frontend: InvitationsModal.jsx (owner surface), AcceptInvitation.jsx
(/invitations/accept route), api.js helpers, RFCView.jsx
Invitations button, App.jsx route registration.
Amplitude wiring (inline, #21 Part C):
- INVITATION_SENT from InvitationsModal { rfc_slug, role_in_rfc }
- INVITATION_ACCEPTED from AcceptInvitation { rfc_slug, role_in_rfc }
- identify() BEFORE the accept event with properties: invited_at
(setOnce), last_invited_to_rfc, last_invite_role_in_rfc,
claim_method: 'rfc-invite'
- EVENTS taxonomy extended with INVITATION_SENT + INVITATION_ACCEPTED
No new secrets, no new overlay keys, no operator gesture beyond the
v0.15.0 overlay-set + restart. Frontend build verified green.
Subagent ν shipped the feature on feature/v0.16.0-owner-invite
(a51beec). Driver-side integration squash-merged into main,
hand-resolved VERSION + package.json + CHANGELOG (strict-descending
0.16.0 → 0.15.0), and added the inline Amplitude wiring.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -90,6 +90,17 @@ def make_router(config: Config) -> APIRouter:
|
||||
|
||||
`permission_decided_by_login` joins the deciding admin row so
|
||||
the UI can render "granted by @ben" without a second round-trip.
|
||||
|
||||
v0.16.0 (roadmap item #12) additive: each user row now carries
|
||||
an `rfc_invitations` array — the per-RFC invitations the user
|
||||
has accepted. This is the "permission-grant requests from
|
||||
invited users" hook the roadmap text calls for: when a user
|
||||
accepts a per-RFC invite and they're not yet platform-granted,
|
||||
the admin sees "here because @ben invited them to <RFC> as
|
||||
<role>" alongside their pending row, informing (not deciding)
|
||||
the platform grant. The two write surfaces remain distinct —
|
||||
the RFC's owner controls per-RFC roles; the admin controls
|
||||
platform-grant state.
|
||||
"""
|
||||
auth.require_admin(request)
|
||||
rows = db.conn().execute(
|
||||
@@ -115,6 +126,36 @@ def make_router(config: Config) -> APIRouter:
|
||||
u.display_name COLLATE NOCASE
|
||||
"""
|
||||
).fetchall()
|
||||
# v0.16.0 — per-user accepted per-RFC invitations. One query
|
||||
# over the full set, indexed bucket-by-user-id in Python so
|
||||
# the per-row attachment below is O(1). Empty array for users
|
||||
# who hold no accepted invitations.
|
||||
invitation_rows = db.conn().execute(
|
||||
"""
|
||||
SELECT c.user_id, c.rfc_slug, c.role_in_rfc, c.created_at,
|
||||
r.title AS rfc_title,
|
||||
i.id AS invitation_id, i.invitee_email,
|
||||
ui.gitea_login AS inviter_login,
|
||||
ui.display_name AS inviter_display
|
||||
FROM rfc_collaborators c
|
||||
LEFT JOIN cached_rfcs r ON r.slug = c.rfc_slug
|
||||
LEFT JOIN rfc_invitations i ON i.id = c.invitation_id
|
||||
LEFT JOIN users ui ON ui.id = i.inviter_user_id
|
||||
ORDER BY c.created_at DESC
|
||||
"""
|
||||
).fetchall()
|
||||
per_user_invites: dict[int, list[dict]] = {}
|
||||
for ir in invitation_rows:
|
||||
per_user_invites.setdefault(ir["user_id"], []).append({
|
||||
"rfc_slug": ir["rfc_slug"],
|
||||
"rfc_title": ir["rfc_title"] or ir["rfc_slug"],
|
||||
"role_in_rfc": ir["role_in_rfc"],
|
||||
"invited_at": ir["created_at"],
|
||||
"invitation_id": ir["invitation_id"],
|
||||
"invitee_email": ir["invitee_email"],
|
||||
"inviter_login": ir["inviter_login"],
|
||||
"inviter_display": ir["inviter_display"],
|
||||
})
|
||||
return {
|
||||
"items": [
|
||||
{
|
||||
@@ -133,6 +174,8 @@ def make_router(config: Config) -> APIRouter:
|
||||
"permission_decided_at": r["permission_decided_at"],
|
||||
"permission_decided_by_login": r["decided_by_login"],
|
||||
"permission_decided_by_display": r["decided_by_display"],
|
||||
# v0.16.0 additive — never null, always an array.
|
||||
"rfc_invitations": per_user_invites.get(r["id"], []),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user