Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 21743a08b1 | |||
| c92730a737 |
+196
@@ -23,6 +23,202 @@ skip versions are the composition of each intervening adjacent
|
||||
release's steps in order — no A-to-B path is pre-computed beyond
|
||||
that.
|
||||
|
||||
## 0.6.0 — 2026-05-28
|
||||
|
||||
**Minor — no operator action required.** A sweep-the-edges hardening
|
||||
release (roadmap item #4, "anon discuss + contribute off-limits") that
|
||||
audits every write-shaped backend endpoint and asserts each one
|
||||
enforces an explicit `auth.require_contributor` (or stricter) gate
|
||||
before doing any state-changing work. v0.3.0 hid write affordances
|
||||
behind a sign-in CTA on the frontend; v0.5.0 added the PR-less
|
||||
discussion surface with its own write gate; v0.6.0 sweeps the rest
|
||||
and adds a regression test net so future endpoints can't quietly
|
||||
ship without a gate. No schema migration, no env-var changes, no
|
||||
new dependencies. The only behavioural change is one tightening: the
|
||||
`GET /api/rfcs/<slug>/graduate/progress` SSE now requires
|
||||
`auth.require_user` since it surfaces operator-visible step detail
|
||||
(repo name, PR number, rollback steps) not part of the v0.3.0
|
||||
anonymous-read contract for catalog/RFC bodies.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`backend/app/api_graduation.py`** — `GET /graduate/progress`
|
||||
now calls `auth.require_user(request)` as its first line.
|
||||
Anonymous callers receive 401 instead of being able to subscribe
|
||||
to a graduation's SSE. The floor is `require_user` (not
|
||||
`require_contributor`) so a write-muted operator can still observe
|
||||
the progress of a graduation they kicked off before being muted.
|
||||
- **SPEC `§6.1`** (`SPEC.md`) — the Anonymous role's bullet now
|
||||
documents the v0.6.0 audit: every write-shaped endpoint in §17
|
||||
enforces an explicit gate; anonymous writes refuse 401. The list
|
||||
of audited write families is recorded in-line.
|
||||
- **SPEC `§10.10`** — the discussion-vs-contribution section now
|
||||
records that the v0.5.0 write gates have a regression test net
|
||||
(`test_anon_offlimits_vertical.py`) added in v0.6.0.
|
||||
- **SPEC `§17`** — the `GET /api/rfcs/<slug>/graduate/progress`
|
||||
bullet now documents the `require_user` gate added in v0.6.0,
|
||||
with the rationale.
|
||||
|
||||
### Added (tests)
|
||||
|
||||
- **`backend/tests/test_anon_offlimits_vertical.py`** — twelve new
|
||||
tests asserting that every write-shaped endpoint surveyed in the
|
||||
v0.6.0 audit refuses anonymous callers with 401, and that the five
|
||||
anonymous-read surfaces (health, philosophy, auth/me, catalog,
|
||||
RFC view, discussion threads, proposals) stay reachable. Sixty-six
|
||||
assertions in total, covering: propose; proposal merge / decline /
|
||||
withdraw; branch promote-to-branch / start-edit-branch / metadata /
|
||||
manual-flush / visibility / grants (POST + DELETE) / threads (POST)
|
||||
/ messages (POST) / resolve / chat-seen / changes (accept / decline
|
||||
/ reask) / chat-stream; super-draft start-edit-branch + metadata;
|
||||
PR pr-draft / open / seen / review / merge / withdraw /
|
||||
description / resolution-branch; discussion thread create + message
|
||||
post + resolve; admin role / mute / allowlist (POST + DELETE) plus
|
||||
the admin reads; notification preferences / quiet-hours / watch /
|
||||
mark-read / user-mute (POST + DELETE); funder credentials (POST +
|
||||
DELETE) + consent (POST + DELETE); graduation kickoff + claim +
|
||||
progress SSE; PR review page anonymous-readable.
|
||||
|
||||
### Anonymous-writeable allowlist
|
||||
|
||||
Two endpoints are intentionally anonymous-by-design. They are not
|
||||
audit findings; they are documented here so the contract is
|
||||
explicit:
|
||||
|
||||
- `GET /auth/login` and `GET /auth/callback` — the OAuth
|
||||
round-trip. Anonymous-by-design because they ARE the sign-in
|
||||
entrypoint.
|
||||
- `POST /api/webhooks/gitea` and `POST /api/webhooks/email-bounce`
|
||||
— anonymous in the session sense but authenticated by HMAC shared
|
||||
secret (`GITEA_WEBHOOK_SECRET` and `WEBHOOK_EMAIL_BOUNCE_SECRET`
|
||||
respectively). The webhook receiver is the wrong place for an
|
||||
authenticated session; the shared-secret shape is correct.
|
||||
|
||||
### §19.2 candidates surfaced
|
||||
|
||||
- None unique to this release. The two pre-existing candidates the
|
||||
audit touched — anonymous-read polish for the discussion surface
|
||||
(carried from v0.5.0) and the operator-visibility floor on
|
||||
graduation progress — were settled here as `require_user` on
|
||||
`/graduate/progress` rather than deferred.
|
||||
|
||||
### Upgrade steps (from 0.5.0)
|
||||
|
||||
1. The deployment **MUST** rebuild the frontend (`npm install &&
|
||||
npm run build`) so the frontend bundle's reported version matches
|
||||
the backend's. No new env vars; existing `frontend/.env` is
|
||||
sufficient.
|
||||
2. The deployment **MUST** restart the backend so the new
|
||||
`require_user` gate on `/graduate/progress` is enforced. No
|
||||
schema migration runs.
|
||||
3. The deployment **MAY** announce the audit completion to
|
||||
operators: every write-shaped backend endpoint now enforces an
|
||||
explicit gate, and the `test_anon_offlimits_vertical.py` test
|
||||
net asserts the contract on every CI run. The audited surfaces
|
||||
are listed in the `Added (tests)` section above and in `SPEC.md`
|
||||
§6.1.
|
||||
4. The deployment **MUST NOT** assume any new envelope behaviour:
|
||||
v0.6.0 is purely a hardening release. No schema, no env, no
|
||||
dependency changes; the operator's role is reduced to rebuild +
|
||||
restart.
|
||||
|
||||
## 0.5.0 — 2026-05-27
|
||||
|
||||
**Minor — no operator action required.** This release wires the
|
||||
PR-less per-RFC discussion surface (roadmap item #3, SPEC §10.10).
|
||||
An RFC's main view now carries a discussion panel distinct from PR
|
||||
comments and branch chat; contribution — proposing edits the document
|
||||
will land — still requires opening a PR via the §10.1 affordance.
|
||||
The substrate is the existing `threads` / `thread_messages` tables;
|
||||
rows with `threads.branch_name IS NULL` scope to the RFC's main view.
|
||||
No schema migration is required.
|
||||
|
||||
### Added
|
||||
|
||||
- **PR-less discussion endpoints** (`backend/app/api_discussion.py`)
|
||||
mounted at `/api/rfcs/<slug>/discussion/...`:
|
||||
- `GET /api/rfcs/<slug>/discussion/threads` — list threads where
|
||||
`threads.branch_name IS NULL`. Anonymous-readable per the v0.3.0
|
||||
contract; the default whole-doc chat thread is materialized
|
||||
lazily on first read.
|
||||
- `POST /api/rfcs/<slug>/discussion/threads` — open a new
|
||||
discussion thread (`thread_kind='chat'`, `anchor_kind='whole-doc'`,
|
||||
`branch_name=NULL`). Body: optional `label`, optional first
|
||||
`message`. Requires contributor role.
|
||||
- `GET /api/rfcs/<slug>/discussion/threads/<thread_id>/messages`
|
||||
— read messages on a discussion thread. Anonymous-readable.
|
||||
- `POST /api/rfcs/<slug>/discussion/threads/<thread_id>/messages`
|
||||
— post a message. Body: `text`, optional `quote`. Requires
|
||||
contributor role.
|
||||
- `POST /api/rfcs/<slug>/discussion/threads/<thread_id>/resolve`
|
||||
— resolve a discussion thread. Permission per §10.10: thread
|
||||
creator, RFC owner / arbiter, or app admin / owner.
|
||||
- **`RFCDiscussionPanel.jsx`** — the right-column surface on the RFC
|
||||
view when the viewer is on `main`. Composer requires sign-in;
|
||||
Cmd/Ctrl+Enter sends. Multiple threads surface as pill-shaped
|
||||
tabs above the message feed. A "New thread" affordance opens a
|
||||
fresh thread on the same RFC.
|
||||
- **SPEC `§10.10` PR-less discussion vs. contribution** — settles
|
||||
the distinction between discussion (RFC-scoped, no PR, both
|
||||
anonymous-readable and contributor-writeable) and contribution
|
||||
(still requires a PR via §10.1). Also extends `§5`'s `threads`
|
||||
table commentary so the null-branch interpretation is documented
|
||||
as actively used rather than reserved scaffold.
|
||||
- **SPEC `§17`** — lists the five new `discussion/...` endpoints
|
||||
in the illustrative table.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`backend/app/chat.py`** — `_fan_out_chat` now passes
|
||||
`branch_name` straight through to the notify chokepoint instead of
|
||||
coercing `None` to `"main"`. The notifications row carries the
|
||||
null through, which preserves the §15.7 reconciler's keying on
|
||||
`(rfc_slug, branch_name)` for the eventual discussion-side
|
||||
chat-seen advance (deferred to a §19.2 candidate). Existing
|
||||
branch-scoped chat continues to pass non-null branch names; the
|
||||
change is invisible to that path.
|
||||
- **`backend/app/notify.py`** — `fan_out_chat_message`'s
|
||||
`branch_name` parameter is now typed `str | None` to match the
|
||||
v0.5.0 PR-less shape. Routing rules are unchanged; the inbox prose
|
||||
renders identically whether the chat lives on a branch or on the
|
||||
RFC's discussion surface, which is the right honest signal.
|
||||
- **`RFCView.jsx`** — the right-column panel is now conditional:
|
||||
when `branchParam === 'main'`, render `RFCDiscussionPanel`
|
||||
(the new PR-less surface); otherwise render the existing
|
||||
`ChatPanel` (branch chat unchanged).
|
||||
|
||||
### §19.2 candidates surfaced
|
||||
|
||||
- PR-less discussion: range / paragraph anchors (the data model
|
||||
permits them; the UI is the deferred part).
|
||||
- PR-less discussion: distinct notification `event_kind`s
|
||||
(`open_rfc_discussion_thread`, etc.) if usage shows contributors
|
||||
want to filter discussion-vs-branch in the §15.2 inbox.
|
||||
- PR-less discussion: chat-seen cursor closing the §15.7
|
||||
reconciliation loop for the new surface.
|
||||
- PR-less discussion: AI participant invocation (discussion-only,
|
||||
no `<change>` block side-effects).
|
||||
- PR-less discussion: anonymous-read polish to match the v0.6.0
|
||||
write-gate hardening (item #4).
|
||||
|
||||
### Upgrade steps (from 0.4.0)
|
||||
|
||||
1. The deployment **MUST** rebuild the frontend (`npm install &&
|
||||
npm run build`) so `RFCDiscussionPanel.jsx` ships in the bundle.
|
||||
No new env vars; existing `frontend/.env` is sufficient.
|
||||
2. The deployment **MUST** restart the backend so the new
|
||||
`api_discussion` router mounts. No schema migration runs — the
|
||||
`threads` table already supports `branch_name IS NULL` per §5,
|
||||
and the v0.5.0 build is the first to write rows in that shape.
|
||||
3. The deployment **MAY** announce the new surface to its
|
||||
contributors: the RFC view's main page now carries a discussion
|
||||
panel below the document. Existing branch chat and PR review
|
||||
surfaces are unchanged.
|
||||
4. The deployment **SHOULD NOT** expect a hardening of the
|
||||
anonymous-write gate in v0.5.0 — that lands in v0.6.0 (item #4).
|
||||
v0.5.0's write paths already refuse anonymous posts, so no
|
||||
pre-emptive operator action is needed.
|
||||
|
||||
## 0.4.0 — 2026-05-27
|
||||
|
||||
**Minor — no operator action required beyond rebuild + restart.** The
|
||||
|
||||
@@ -253,13 +253,18 @@ and exact columns are illustrative; the implementing session can adjust.
|
||||
- `threads` — every conversation in the system, whether scoped to an RFC's
|
||||
main view, a branch, or a span within a branch's document. Columns:
|
||||
`id`, `rfc_slug`, `branch_name` (nullable — null means scoped to the
|
||||
RFC's main view), `anchor_kind` (`whole-doc` | `range` | `paragraph`),
|
||||
RFC's main view, the PR-less per-RFC discussion surface per §10's
|
||||
closing note; non-null means scoped to a branch's work, including
|
||||
PR-comment threads), `anchor_kind` (`whole-doc` | `range` | `paragraph`),
|
||||
`anchor_payload` (JSON: serialized ProseMirror range or paragraph id),
|
||||
`thread_kind` (`chat` | `flag` | `review` — `review` is the diff-anchored
|
||||
PR-review thread defined in §10.4), `label` (short human-authored summary;
|
||||
for flags this is the entire content), `state` (`open` | `resolved` |
|
||||
`stale`), `created_by`, `created_at`, `resolved_at`, `resolved_by`.
|
||||
Visibility is derived from the underlying branch (§11.1).
|
||||
Visibility is derived from the underlying branch (§11.1); for the
|
||||
null-branch PR-less discussion surface, visibility follows the RFC
|
||||
(anonymous read open per the §14 / v0.3.0 contract, write requires
|
||||
contributor per §6.1, tightened toward anon-write-refused in v0.6.0).
|
||||
- `thread_messages` — the actual chat content for `chat`-kind threads.
|
||||
Columns: `id`, `thread_id`, `role` (`user` | `assistant` | `system`),
|
||||
`author_user_id` (nullable; null for assistant), `model_id` (nullable;
|
||||
@@ -349,7 +354,15 @@ Authorization is owned by the app. Gitea sees only the bot account.
|
||||
1. **Anonymous.** Can read public RFCs (the meta repo's main branch,
|
||||
every RFC repo's main branch), read any branch whose `read_public`
|
||||
is true, read any PR. Cannot chat, propose, create branches, or
|
||||
open PRs.
|
||||
open PRs. v0.6.0 (roadmap item #4) closed the audit: every
|
||||
write-shaped endpoint surveyed in §17 enforces an explicit
|
||||
`auth.require_contributor` (or stricter) gate before doing any
|
||||
state-changing work; anonymous writes refuse 401. The explicit
|
||||
audit covers propose, branch create, branch threads, PR-less
|
||||
discussion threads + messages, PR open / merge / withdraw,
|
||||
funder credentials + consent, admin allowlist add, graduation
|
||||
kickoff + claim. Anonymous reads on every catalog and RFC-body
|
||||
surface remain open per the v0.3.0 contract.
|
||||
2. **Contributor.** Default role for any authenticated account.
|
||||
Everything anonymous can do, plus: propose new RFCs (open a PR
|
||||
against the meta repo), create branches on any RFC repo, open PRs
|
||||
@@ -1639,6 +1652,44 @@ framework's evidence unit; admitting plumbing commits — "fix merge
|
||||
conflict with main" — into that timeline would dilute the signal each
|
||||
commit is meant to carry.
|
||||
|
||||
### 10.10 PR-less discussion vs. contribution
|
||||
|
||||
PR comments and branch chat (§10.4, §8.4) are PR-scoped: they live on
|
||||
the `threads` rows whose `branch_name` names a branch (the PR's head
|
||||
or, pre-PR, a feature branch). They are the right surface for *this
|
||||
specific proposed change*. They are not the right surface for "what
|
||||
about this part of the RFC overall?" or "have we considered…?" — a
|
||||
question that doesn't yet warrant cutting a branch and that would
|
||||
distort a PR's review timeline if it landed there.
|
||||
|
||||
The RFC view carries a **discussion surface** distinct from PR
|
||||
comments. Its substrate is `threads` rows whose `branch_name IS NULL`
|
||||
(§5) — the same conversation table used by branch chat, with the
|
||||
nullable column doing the segregating. Posting a discussion thread or
|
||||
message does not open a PR; the §1 chokepoint is unaffected because
|
||||
chat messages never produced Git writes. Contribution — proposing
|
||||
edits the document will land — still requires opening a PR via the
|
||||
§10.1 affordance.
|
||||
|
||||
The distinction in one line: **discussion is what the RFC is for;
|
||||
contribution is how the RFC changes.** Either is honest; conflating
|
||||
them was the failure mode of generic-PR-comments-as-only-conversation.
|
||||
|
||||
Reads on the discussion surface follow §14 / the v0.3.0 anonymous-read
|
||||
contract: anyone can see the conversation. Writes require contributor
|
||||
role per §6.1: v0.5.0 implemented the gate on the three discussion
|
||||
write paths (POST threads, POST messages, POST resolve); v0.6.0 (item
|
||||
#4) audited the adjacent surfaces and added the matching test net
|
||||
(`test_anon_offlimits_vertical.py`) so a regression on any write
|
||||
endpoint is caught immediately. The gates use `auth.require_contributor`
|
||||
as the canonical helper. The notification routing reuses the
|
||||
existing `chat_message_in_participated_thread` /
|
||||
`chat_reply_to_my_message` event kinds with `branch_name=null` on the
|
||||
fan-out row; the §15.7 reconciler and §15 inbox prose render
|
||||
identically whether the chat lives on a branch or on the RFC's
|
||||
discussion surface. A distinct `open_rfc_discussion_thread` event
|
||||
kind is a §19.2 candidate if evidence demands the split.
|
||||
|
||||
---
|
||||
|
||||
## 11. Branches and PRs: visibility, contribute, lifecycle
|
||||
@@ -2505,7 +2556,13 @@ The follow-up session will refine this. A minimal starting set:
|
||||
trailing `rollback` step's events if any earlier step fails. The
|
||||
Graduate dialog opens this stream on confirm and renders the step
|
||||
stack from the events. The stream closes on success or on
|
||||
rollback completion.
|
||||
rollback completion. Requires `auth.require_user` per v0.6.0
|
||||
(item #4): the step detail (repo name, PR number, rollback steps)
|
||||
is operator-visible state and isn't part of the v0.3.0
|
||||
anonymous-read contract for catalog and RFC bodies. The floor is
|
||||
`require_user` (not `require_contributor`) so a write-muted
|
||||
operator can still observe a graduation they kicked off before
|
||||
being muted.
|
||||
- `GET /api/rfcs/<slug>/blocking-prs` — list open meta-repo PRs
|
||||
against `rfcs/<slug>.md` per §13.2's precondition popover. Returns
|
||||
PR number, title, author, last-activity timestamp, and the
|
||||
@@ -2552,6 +2609,26 @@ The follow-up session will refine this. A minimal starting set:
|
||||
- `POST /api/rfcs/<slug>/branches/<branch>/threads/<thread_id>/resolve`
|
||||
— resolve a thread per §8.12; permission per the rules in that
|
||||
section.
|
||||
- `GET /api/rfcs/<slug>/discussion/threads` — list threads on the
|
||||
RFC's PR-less discussion surface per §10.10 (rows where
|
||||
`threads.branch_name IS NULL`). Anonymous-readable per the v0.3.0
|
||||
anonymous-read contract; the default whole-doc chat thread is
|
||||
materialized lazily on first read, mirroring the §8.12 branch-chat
|
||||
default.
|
||||
- `POST /api/rfcs/<slug>/discussion/threads` — open a discussion
|
||||
thread per §10.10. Body: optional `label` (short summary), optional
|
||||
first `message`. Writes require contributor role; anonymous viewers
|
||||
receive 401. Thread is created with `anchor_kind='whole-doc'`,
|
||||
`thread_kind='chat'`, `branch_name=NULL`.
|
||||
- `GET /api/rfcs/<slug>/discussion/threads/<thread_id>/messages` —
|
||||
read messages on a discussion thread. Anonymous-readable.
|
||||
- `POST /api/rfcs/<slug>/discussion/threads/<thread_id>/messages` —
|
||||
post a message into a discussion thread per §10.10. Body: `text`,
|
||||
optional `quote`. Writes require contributor role.
|
||||
- `POST /api/rfcs/<slug>/discussion/threads/<thread_id>/resolve` —
|
||||
resolve a discussion thread per §10.10; permission collapses to the
|
||||
thread creator, any RFC owner / arbiter per §6.3, and any app
|
||||
admin / owner per §6.1.
|
||||
- `POST /api/rfcs/<slug>/branches/<branch>/open-pr` — open a PR per
|
||||
§10.1; body carries the AI-drafted (and possibly edited) title and
|
||||
description.
|
||||
@@ -3287,6 +3364,55 @@ binding.
|
||||
("operators MAY configure their monitoring to probe `/api/health`;
|
||||
the endpoint is unauthenticated by design"). §17 now lists the
|
||||
endpoint in its illustrative table.*
|
||||
- **PR-less discussion: range and paragraph anchors.** v0.5.0 lands
|
||||
the structural discussion surface (§10.10) but constrains every
|
||||
PR-less thread to `anchor_kind='whole-doc'` — the data model permits
|
||||
`range` and `paragraph` anchors (§5) but the UI work to surface a
|
||||
passage-anchored thread on a non-branch view is the deferred half.
|
||||
The natural follow-on is a margin-icon affordance on the main view
|
||||
matching §8.12's branch-side surface, with the anchor stored on the
|
||||
null-branch thread. Earns its session when discussion volume warrants
|
||||
the precision; v0.5.0's flat surface is sufficient for most "have we
|
||||
considered…?" gestures. Touches §10.10 and §8.12.
|
||||
- **PR-less discussion: distinct notification event_kinds.** v0.5.0
|
||||
routes per-RFC discussion messages through the existing
|
||||
`chat_message_in_participated_thread` and `chat_reply_to_my_message`
|
||||
event kinds with `branch_name=null` on the fan-out row. The inbox
|
||||
prose reads identically whether the chat lives on a branch or on the
|
||||
RFC's discussion surface, which is honest signal: the conversation
|
||||
shape is the same; only the scope differs. A future session may
|
||||
introduce `open_rfc_discussion_thread` / `post_rfc_discussion_message`
|
||||
if evidence shows contributors want to filter discussion-vs-branch
|
||||
chat distinctly in the §15.2 inbox. Touches §15.1 (the event_kind
|
||||
enum), §15.2 (the inbox filter chips), and §10.10.
|
||||
- **PR-less discussion: chat-seen cursor.** §15.7 commits the
|
||||
`branch_chat_seen` cursor for branch-scoped chat. The PR-less
|
||||
discussion surface has no equivalent cursor in v0.5.0; the inbox
|
||||
reconciler's keying on `(rfc_slug, branch_name)` does match a
|
||||
null-branch advance, but no write path advances it. A natural
|
||||
follow-on is a sibling table — `rfc_discussion_seen` or a
|
||||
null-branch row on `branch_chat_seen` — that the discussion panel
|
||||
advances on read, closing the §15.7 reconciliation loop for
|
||||
discussion-surface notifications. Defer-able until inbox volume on
|
||||
the new surface shows it matters.
|
||||
- **PR-less discussion: AI participation.** v0.5.0's discussion
|
||||
surface is human-only — no AI participant invocation, no `<change>`
|
||||
block parsing, no per-thread model picker. The branch chat (§8.12)
|
||||
retains the §18 AI surface. The natural follow-on is wiring the AI
|
||||
participant into discussion threads (the model picker, the
|
||||
`Ask Claude` button on a selection tooltip) without enabling
|
||||
document edits — a discussion-only AI turn produces only chat
|
||||
content, no `changes` row, no commit. Contribution still requires a
|
||||
PR; the AI's discussion-side help is just better prompts. Touches
|
||||
§10.10, §8.12, and §18.
|
||||
- **PR-less discussion: anonymous read polish.** v0.5.0 inherits the
|
||||
v0.3.0 anonymous-read contract — anyone can read; only signed-in
|
||||
contributors can write. The composer affordance for anonymous
|
||||
viewers ("Sign in to comment.") matches the existing read-only-bar
|
||||
treatment but the surface has not yet been audited for the v0.6.0
|
||||
hardening that tightens write gates app-wide. The §19.2 "public
|
||||
face of discuss mode" entry overlaps; this entry is its discussion-
|
||||
surface variant.
|
||||
- **Deployment-supplied subject framing.** The framework was built
|
||||
with one deployment in mind (OHM, standardizing natural-language
|
||||
vocabulary), but the substrate generalizes to any domain that
|
||||
|
||||
@@ -20,6 +20,7 @@ from pydantic import BaseModel, Field
|
||||
from . import (
|
||||
api_admin,
|
||||
api_branches,
|
||||
api_discussion,
|
||||
api_graduation,
|
||||
api_notifications,
|
||||
api_prs,
|
||||
@@ -81,6 +82,12 @@ def make_router(
|
||||
# the §15.8 mute typeahead) and the §6/§17 admin surfaces
|
||||
# (role, write-mute, audit-log, graduation-readiness queue).
|
||||
router.include_router(api_admin.make_router(config))
|
||||
# v0.5.0: §5 / §7 / §10 — PR-less per-RFC discussion endpoints.
|
||||
# The substrate is the existing threads/thread_messages tables;
|
||||
# rows whose branch_name IS NULL scope to the RFC's main view.
|
||||
# Contribution still requires a PR (api_prs above); this surface
|
||||
# is for discussion that does not yet warrant a branch.
|
||||
router.include_router(api_discussion.make_router())
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# §17: /api/health — unauthenticated post-flight probe.
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
"""§5 / §7 / §10 — PR-less per-RFC discussion endpoints (v0.5.0).
|
||||
|
||||
This module surfaces the discussion-without-PR shape committed by the
|
||||
roadmap's item #3. The substrate is the existing `threads` /
|
||||
`thread_messages` pair from §5: rows whose `branch_name` is NULL are
|
||||
scoped to the RFC's main view (the schema comment on the column says
|
||||
exactly this; until now no write path produced such rows). This module
|
||||
is the read+write surface for those rows.
|
||||
|
||||
Contribution still requires a PR: the §10 PR flow is unchanged, the
|
||||
branch-scoped chat in `api_branches.py` is unchanged, and accept /
|
||||
decline of AI `<change>` blocks still lives on a branch. What this
|
||||
module adds is the "discuss freely about the RFC, no branch yet" surface
|
||||
— a place to drop a question, a flag-style observation, or a multi-turn
|
||||
conversation that does not yet warrant cutting a branch.
|
||||
|
||||
Auth shape mirrors the v0.3.0 anonymous-read contract: reads are open,
|
||||
writes require `auth.require_contributor`. Item #4 ("anon discuss/
|
||||
contribute off-limits") tightens the read gate in v0.6.0; v0.5.0's
|
||||
write gate already holds the line.
|
||||
|
||||
Notification routing reuses the existing `fan_out_chat_message` path
|
||||
with `branch_name=None`; the `notifications.branch_name` column is
|
||||
nullable, and the inbox row prose ("@alice posted a chat message on
|
||||
<RFC title>") renders identically whether the chat lives on a branch
|
||||
or on the RFC's discussion surface. The existing
|
||||
`chat_message_in_participated_thread` / `chat_reply_to_my_message`
|
||||
event kinds carry both shapes; introducing a parallel
|
||||
`open_rfc_discussion_thread` / `post_rfc_discussion_message` enum pair
|
||||
would split routing without adding signal. The §15 §19.2 candidate
|
||||
"distinct event_kinds for PR-less discussion" notes the option for a
|
||||
future session if evidence demands the split.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from . import auth, chat as chat_layer, db
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request bodies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class DiscussionThreadCreateBody(BaseModel):
|
||||
"""A discussion thread is a `thread_kind='chat'`, `anchor_kind='whole-doc'`,
|
||||
`branch_name=NULL` row. Anchored-range / per-paragraph threads on the
|
||||
RFC discussion surface are a §19.2 candidate — the schema supports
|
||||
them; the UI work to surface a range-anchor on a non-branch view is
|
||||
the deferred part. v0.5.0 keeps the shape narrow."""
|
||||
label: str | None = Field(default=None, max_length=400)
|
||||
message: str | None = Field(default=None, max_length=20_000)
|
||||
|
||||
|
||||
class DiscussionMessageBody(BaseModel):
|
||||
text: str = Field(min_length=1, max_length=20_000)
|
||||
quote: str | None = Field(default=None, max_length=2000)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Router
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def make_router() -> APIRouter:
|
||||
router = APIRouter()
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# GET /api/rfcs/<slug>/discussion/threads
|
||||
# Lists every PR-less thread on the RFC. The default whole-doc thread
|
||||
# is materialized lazily on first list (mirroring the §8.12 branch-
|
||||
# chat default-thread treatment) so the UI always has a target for
|
||||
# the compose-message affordance.
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
@router.get("/api/rfcs/{slug}/discussion/threads")
|
||||
async def list_discussion_threads(slug: str, request: Request) -> dict[str, Any]:
|
||||
viewer = auth.current_user(request)
|
||||
_require_rfc_readable(slug)
|
||||
# Ensure the default whole-doc discussion thread exists. We mint
|
||||
# it on first read regardless of viewer (anonymous viewers can
|
||||
# trigger the creation — the row's `created_by` is null in that
|
||||
# case, mirroring `_ensure_branch_chat_thread`).
|
||||
_ensure_discussion_thread(slug, viewer)
|
||||
rows = db.conn().execute(
|
||||
"""
|
||||
SELECT id, anchor_kind, anchor_payload, thread_kind, label, state,
|
||||
created_by, created_at, resolved_at, resolved_by
|
||||
FROM threads
|
||||
WHERE rfc_slug = ? AND branch_name IS NULL
|
||||
ORDER BY id
|
||||
""",
|
||||
(slug,),
|
||||
).fetchall()
|
||||
return {"items": [_serialize_thread(r) for r in rows]}
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# POST /api/rfcs/<slug>/discussion/threads
|
||||
# Open a fresh discussion thread. Writes require require_contributor
|
||||
# — anonymous viewers can read but cannot open a thread, per item
|
||||
# #4's hardening anticipated in v0.6.0 (we already enforce it here
|
||||
# to avoid the open window).
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
@router.post("/api/rfcs/{slug}/discussion/threads")
|
||||
async def create_discussion_thread(
|
||||
slug: str, body: DiscussionThreadCreateBody, request: Request
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
_require_rfc_readable(slug)
|
||||
cur = db.conn().execute(
|
||||
"""
|
||||
INSERT INTO threads
|
||||
(rfc_slug, branch_name, anchor_kind, anchor_payload,
|
||||
thread_kind, label, created_by)
|
||||
VALUES (?, NULL, 'whole-doc', NULL, 'chat', ?, ?)
|
||||
""",
|
||||
(slug, body.label, viewer.user_id),
|
||||
)
|
||||
thread_id = cur.lastrowid
|
||||
message_id = None
|
||||
if body.message:
|
||||
message_id = chat_layer.append_user_message(
|
||||
thread_id=thread_id,
|
||||
author_user_id=viewer.user_id,
|
||||
text=body.message,
|
||||
quote=None,
|
||||
)
|
||||
return {"thread_id": thread_id, "message_id": message_id}
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# GET /api/rfcs/<slug>/discussion/threads/<thread_id>/messages
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
@router.get("/api/rfcs/{slug}/discussion/threads/{thread_id}/messages")
|
||||
async def get_discussion_thread_messages(
|
||||
slug: str, thread_id: int, request: Request
|
||||
) -> dict[str, Any]:
|
||||
_viewer = auth.current_user(request)
|
||||
_require_rfc_readable(slug)
|
||||
thread = _require_discussion_thread(slug, thread_id)
|
||||
rows = db.conn().execute(
|
||||
"""
|
||||
SELECT m.id, m.role, m.author_user_id,
|
||||
u.gitea_login AS author_login,
|
||||
u.display_name AS author_display,
|
||||
m.model_id, m.text, m.quote, m.created_at
|
||||
FROM thread_messages m
|
||||
LEFT JOIN users u ON u.id = m.author_user_id
|
||||
WHERE m.thread_id = ?
|
||||
ORDER BY m.id
|
||||
""",
|
||||
(thread_id,),
|
||||
).fetchall()
|
||||
return {
|
||||
"thread": _serialize_thread(thread),
|
||||
"messages": [_serialize_message(r) for r in rows],
|
||||
}
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# POST /api/rfcs/<slug>/discussion/threads/<thread_id>/messages
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
@router.post("/api/rfcs/{slug}/discussion/threads/{thread_id}/messages")
|
||||
async def post_discussion_message(
|
||||
slug: str, thread_id: int, body: DiscussionMessageBody, request: Request
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
_require_rfc_readable(slug)
|
||||
_require_discussion_thread(slug, thread_id)
|
||||
message_id = chat_layer.append_user_message(
|
||||
thread_id=thread_id,
|
||||
author_user_id=viewer.user_id,
|
||||
text=body.text,
|
||||
quote=body.quote,
|
||||
)
|
||||
return {"ok": True, "message_id": message_id}
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# POST /api/rfcs/<slug>/discussion/threads/<thread_id>/resolve
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
@router.post("/api/rfcs/{slug}/discussion/threads/{thread_id}/resolve")
|
||||
async def resolve_discussion_thread(
|
||||
slug: str, thread_id: int, request: Request
|
||||
) -> dict[str, Any]:
|
||||
viewer = auth.require_contributor(request)
|
||||
rfc = _require_rfc_readable(slug)
|
||||
thread = _require_discussion_thread(slug, thread_id)
|
||||
if not _can_resolve(rfc, thread, viewer):
|
||||
raise HTTPException(
|
||||
403,
|
||||
"Only the thread creator, an RFC owner/arbiter, or an app admin/owner may resolve",
|
||||
)
|
||||
db.conn().execute(
|
||||
"""
|
||||
UPDATE threads
|
||||
SET state = 'resolved',
|
||||
resolved_by = ?,
|
||||
resolved_at = datetime('now')
|
||||
WHERE id = ?
|
||||
""",
|
||||
(viewer.user_id, thread_id),
|
||||
)
|
||||
return {"ok": True, "thread_id": thread_id}
|
||||
|
||||
return router
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _require_rfc_readable(slug: str):
|
||||
"""Per the v0.3.0 anonymous-read contract: any cached RFC is readable
|
||||
by anyone. Withdrawn entries refuse reads of every shape — same rule
|
||||
`_require_rfc_with_repo` in `api_branches.py` follows."""
|
||||
row = db.conn().execute(
|
||||
"SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise HTTPException(404, "RFC not found")
|
||||
if row["state"] == "withdrawn":
|
||||
raise HTTPException(409, "RFC is withdrawn")
|
||||
return row
|
||||
|
||||
|
||||
def _require_discussion_thread(slug: str, thread_id: int):
|
||||
"""A discussion thread is one whose (rfc_slug, branch_name) = (slug,
|
||||
NULL). Refuse cleanly if the thread id resolves to a branch-scoped
|
||||
thread instead — that lookup belongs on the branch endpoints."""
|
||||
row = db.conn().execute(
|
||||
"""
|
||||
SELECT * FROM threads
|
||||
WHERE id = ? AND rfc_slug = ? AND branch_name IS NULL
|
||||
""",
|
||||
(thread_id, slug),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Discussion thread not found")
|
||||
return row
|
||||
|
||||
|
||||
def _ensure_discussion_thread(slug: str, viewer) -> int:
|
||||
"""Per the §8.12 lazy-create pattern, materialize a default whole-doc
|
||||
chat thread on the RFC's discussion surface on first read. Created_by
|
||||
is null when an anonymous viewer triggers creation — the thread is
|
||||
structurally owned by the RFC, not by whoever opened the view."""
|
||||
row = db.conn().execute(
|
||||
"""
|
||||
SELECT id FROM threads
|
||||
WHERE rfc_slug = ? AND branch_name IS NULL
|
||||
AND anchor_kind = 'whole-doc' AND thread_kind = 'chat'
|
||||
ORDER BY id LIMIT 1
|
||||
""",
|
||||
(slug,),
|
||||
).fetchone()
|
||||
if row:
|
||||
return row["id"]
|
||||
cur = db.conn().execute(
|
||||
"""
|
||||
INSERT INTO threads
|
||||
(rfc_slug, branch_name, anchor_kind, thread_kind, label, created_by)
|
||||
VALUES (?, NULL, 'whole-doc', 'chat', NULL, ?)
|
||||
""",
|
||||
(slug, viewer.user_id if viewer else None),
|
||||
)
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def _can_resolve(rfc, thread, viewer) -> bool:
|
||||
if viewer is None:
|
||||
return False
|
||||
if viewer.role in ("owner", "admin"):
|
||||
return True
|
||||
owners = json.loads(rfc["owners_json"] or "[]")
|
||||
arbiters = json.loads(rfc["arbiters_json"] or "[]")
|
||||
if viewer.gitea_login in owners or viewer.gitea_login in arbiters:
|
||||
return True
|
||||
if thread["created_by"] == viewer.user_id:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Serializers — mirror api_branches.py's shape
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _serialize_thread(row) -> dict[str, Any]:
|
||||
payload = row["anchor_payload"]
|
||||
try:
|
||||
anchor = json.loads(payload) if payload else None
|
||||
except Exception:
|
||||
anchor = None
|
||||
return {
|
||||
"id": row["id"],
|
||||
"anchor_kind": row["anchor_kind"],
|
||||
"anchor_payload": anchor,
|
||||
"thread_kind": row["thread_kind"],
|
||||
"label": row["label"],
|
||||
"state": row["state"],
|
||||
"created_by": row["created_by"],
|
||||
"created_at": row["created_at"],
|
||||
"resolved_at": row["resolved_at"] if "resolved_at" in row.keys() else None,
|
||||
"resolved_by": row["resolved_by"] if "resolved_by" in row.keys() else None,
|
||||
}
|
||||
|
||||
|
||||
def _serialize_message(row) -> dict[str, Any]:
|
||||
return {
|
||||
"id": row["id"],
|
||||
"role": row["role"],
|
||||
"author_user_id": row["author_user_id"],
|
||||
"author_login": row["author_login"],
|
||||
"author_display": row["author_display"],
|
||||
"model_id": row["model_id"],
|
||||
"text": row["text"],
|
||||
"quote": row["quote"],
|
||||
"created_at": row["created_at"],
|
||||
}
|
||||
@@ -520,7 +520,16 @@ def make_router(
|
||||
|
||||
@router.get("/api/rfcs/{slug}/graduate/progress")
|
||||
async def graduate_progress(slug: str, request: Request):
|
||||
del request
|
||||
# v0.6.0 (item #4): the progress SSE surfaces admin-internal step
|
||||
# detail (repo name, PR number, rollback steps) that isn't part of
|
||||
# the v0.3.0 anonymous-read contract for catalog/RFC bodies. The
|
||||
# corresponding POST /graduate is gated to RFC owners/arbiters and
|
||||
# app admins/owners via `_can_graduate`; the read SSE shares that
|
||||
# operator-visible surface, so it requires at least an
|
||||
# authenticated viewer. We keep the floor at require_user (not
|
||||
# require_contributor) so a write-muted operator can still observe
|
||||
# the progress of a graduation they kicked off before being muted.
|
||||
auth.require_user(request)
|
||||
state = _get_active(slug)
|
||||
if state is None:
|
||||
raise HTTPException(404, "No graduation in flight for this slug")
|
||||
|
||||
+6
-1
@@ -168,10 +168,15 @@ def _fan_out_chat(thread_id: int, author_user_id: int, message_id: int) -> None:
|
||||
).fetchone()
|
||||
if pr_row:
|
||||
pr_number = pr_row["pr_number"]
|
||||
# v0.5.0 (§5 / §10 — PR-less discussion): a thread with
|
||||
# branch_name IS NULL is scoped to the RFC's main view. Pass None
|
||||
# through to the notify chokepoint so the notifications row keeps
|
||||
# `branch_name` null — coercing it to "main" would misroute the
|
||||
# §15.7 chat-seen reconciler (which keys on branch_name).
|
||||
notify.fan_out_chat_message(
|
||||
actor_user_id=author_user_id,
|
||||
rfc_slug=row["rfc_slug"],
|
||||
branch_name=row["branch_name"] or "main",
|
||||
branch_name=row["branch_name"],
|
||||
thread_id=thread_id,
|
||||
message_id=message_id,
|
||||
is_review_thread=(row["thread_kind"] == "review"),
|
||||
|
||||
@@ -212,7 +212,7 @@ def fan_out_chat_message(
|
||||
*,
|
||||
actor_user_id: int,
|
||||
rfc_slug: str,
|
||||
branch_name: str,
|
||||
branch_name: str | None,
|
||||
thread_id: int,
|
||||
message_id: int,
|
||||
is_review_thread: bool = False,
|
||||
@@ -227,6 +227,14 @@ def fan_out_chat_message(
|
||||
(state='watching', i.e. full stream) get a churn-class
|
||||
`chat_message_in_participated_thread`. The two are union'd so a user
|
||||
who is both gets only the personal-direct row.
|
||||
|
||||
v0.5.0: `branch_name` may be None — that is the PR-less per-RFC
|
||||
discussion shape (`threads.branch_name IS NULL`, §5). The
|
||||
notifications row carries the null through; the inbox prose renders
|
||||
identically whether the chat lives on a branch or on the RFC's
|
||||
discussion surface, and the §15.7 reconciler keys on
|
||||
(rfc_slug, branch_name) so a null branch correctly matches the
|
||||
PR-less discussion's eventual chat-seen-equivalent advance.
|
||||
"""
|
||||
_bump_auto_watch(actor_user_id, rfc_slug)
|
||||
|
||||
|
||||
@@ -0,0 +1,476 @@
|
||||
"""v0.6.0 (roadmap item #4) — "anon discuss + contribute off-limits"
|
||||
vertical.
|
||||
|
||||
A sweep-the-edges hardening release. The v0.3.0 release hid the write
|
||||
affordances from anonymous viewers; v0.5.0 added the PR-less discussion
|
||||
surface with its own write gate. v0.6.0 audits both: every write-shaped
|
||||
endpoint refuses anonymous callers with 401 (or 403 when the role check
|
||||
runs after the auth check), and every anonymous-read surface stays
|
||||
reachable.
|
||||
|
||||
This test is the regression net for the audit. It walks each module's
|
||||
representative write endpoint as an anonymous client and asserts the
|
||||
401/403, then walks the same surfaces' representative read endpoints
|
||||
as anonymous and asserts the 200. The intent is breadth over depth:
|
||||
one assertion per write endpoint family is enough to catch a
|
||||
regression where someone strips the `auth.require_contributor` line.
|
||||
|
||||
Endpoints covered (one or two from each module):
|
||||
|
||||
- api.py: propose, decline (admin), withdraw,
|
||||
funder credentials POST/DELETE, funder consent
|
||||
POST/DELETE
|
||||
- api_branches.py: promote-to-branch, start-edit-branch, metadata,
|
||||
manual-flush, visibility, grants POST/DELETE,
|
||||
threads POST, thread messages POST, resolve,
|
||||
chat-seen, change accept/decline/reask
|
||||
- api_prs.py: pr-draft, open-pr, seen, review, merge, withdraw,
|
||||
description, resolution-branch
|
||||
- api_discussion.py: thread create, message post, resolve
|
||||
- api_admin.py: role POST, mute POST, allowlist POST/DELETE
|
||||
- api_notifications.py: prefs POST, watch POST, mark-read POST,
|
||||
quiet-hours POST, user-mute POST/DELETE
|
||||
- api_graduation.py: graduate POST, claim POST, progress GET
|
||||
|
||||
The §15.7 reads (`/api/notifications`, `/api/watches`,
|
||||
`/api/users/me/*`) are per-user surfaces — they require an
|
||||
authenticated viewer by definition; an anonymous 401 on those reads is
|
||||
shape-correct, not a regression. The test does not assert reads on
|
||||
those.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
# Reuse the fixture / session / fake-Gitea harness from Slice 1.
|
||||
from test_propose_vertical import ( # noqa: F401 — fixtures land via import
|
||||
FakeGitea,
|
||||
app_with_fake_gitea,
|
||||
provision_user_row,
|
||||
sign_in_as,
|
||||
tmp_env,
|
||||
)
|
||||
from test_rfc_view_vertical import SEED_BODY, seed_active_rfc
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_anonymous_can_read_every_public_surface(app_with_fake_gitea):
|
||||
"""Per §14 / the v0.3.0 anonymous-read contract: the catalog, the
|
||||
RFC view, the PR-less discussion surface, the philosophy page, and
|
||||
the health probe must remain reachable for unauthenticated viewers.
|
||||
This is the read side of the item #4 contract — the read surfaces
|
||||
must NOT regress to require auth as the write gates tighten.
|
||||
"""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
|
||||
# No session cookie — viewer is anonymous.
|
||||
client.cookies.clear()
|
||||
|
||||
# The five read surfaces an anonymous viewer must reach.
|
||||
assert client.get("/api/health").status_code == 200
|
||||
assert client.get("/api/philosophy").status_code == 200
|
||||
assert client.get("/api/auth/me").status_code == 200
|
||||
assert client.get("/api/rfcs").status_code == 200
|
||||
assert client.get("/api/rfcs/ohm").status_code == 200
|
||||
assert client.get("/api/rfcs/ohm/main").status_code == 200
|
||||
assert client.get("/api/rfcs/ohm/discussion/threads").status_code == 200
|
||||
assert client.get("/api/proposals").status_code == 200
|
||||
|
||||
|
||||
def test_anonymous_propose_refused(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
client.cookies.clear()
|
||||
r = client.post(
|
||||
"/api/rfcs/propose",
|
||||
json={"title": "X", "slug": "x", "pitch": "p", "tags": []},
|
||||
)
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_anonymous_proposal_admin_paths_refused(app_with_fake_gitea):
|
||||
"""The admin-gated proposal actions — merge, decline — must refuse
|
||||
anonymous callers with 401 (the auth check runs before the role
|
||||
check; both refusals are correct, but 401 is the structural signal
|
||||
"no session at all")."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
client.cookies.clear()
|
||||
# PR number doesn't need to exist — the gate runs first.
|
||||
assert client.post("/api/proposals/1/merge").status_code == 401
|
||||
assert (
|
||||
client.post("/api/proposals/1/decline", json={"comment": "no"}).status_code
|
||||
== 401
|
||||
)
|
||||
assert client.post("/api/proposals/1/withdraw").status_code == 401
|
||||
|
||||
|
||||
def test_anonymous_branch_writes_refused_on_active_rfc(app_with_fake_gitea):
|
||||
"""Branch-scoped writes on an active RFC: promote-to-branch,
|
||||
manual-flush, visibility, grants, threads create, message post,
|
||||
resolve, chat-seen, change accept/decline/reask. All must 401 for
|
||||
anonymous callers."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
client.cookies.clear()
|
||||
|
||||
# Branch-scoped writes — slug + branch values are placeholders;
|
||||
# the auth gate runs before any state lookup.
|
||||
slug = "ohm"
|
||||
branch = "feature-x"
|
||||
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/branches/main/promote-to-branch",
|
||||
json={},
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/branches/{branch}/manual-flush",
|
||||
json={"new_content": "hi", "paragraph_count": 1},
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/branches/{branch}/visibility",
|
||||
json={"read_public": False},
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/branches/{branch}/grants",
|
||||
json={"grantee_gitea_login": "alice"},
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.delete(
|
||||
f"/api/rfcs/{slug}/branches/{branch}/grants/alice",
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/branches/{branch}/threads",
|
||||
json={"thread_kind": "chat", "anchor_kind": "whole-doc"},
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/branches/{branch}/threads/1/messages",
|
||||
json={"text": "hi"},
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/branches/{branch}/threads/1/resolve",
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/branches/{branch}/chat-seen",
|
||||
json={"last_seen_message_id": 1},
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/branches/{branch}/changes/1/accept",
|
||||
json={"proposed": "x"},
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/branches/{branch}/changes/1/decline",
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/branches/{branch}/changes/1/reask",
|
||||
).status_code == 401
|
||||
)
|
||||
# Chat stream — POST shaped, same auth gate.
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/branches/{branch}/threads/1/chat",
|
||||
json={"text": "hi"},
|
||||
).status_code == 401
|
||||
)
|
||||
|
||||
|
||||
def test_anonymous_super_draft_writes_refused(app_with_fake_gitea):
|
||||
"""Super-draft-scoped writes: start-edit-branch and metadata. The
|
||||
PR open / merge paths share the gate via api_prs.py — see the
|
||||
PR-flow test below for those."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
client.cookies.clear()
|
||||
assert (
|
||||
client.post(
|
||||
"/api/rfcs/anything/start-edit-branch", json={}
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
"/api/rfcs/anything/metadata", json={"title": "x"}
|
||||
).status_code == 401
|
||||
)
|
||||
|
||||
|
||||
def test_anonymous_pr_flow_writes_refused(app_with_fake_gitea):
|
||||
"""All §10 PR-flow writes — open, merge, withdraw, description,
|
||||
review, seen, pr-draft, resolution-branch — must 401 for anonymous."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
client.cookies.clear()
|
||||
slug, branch, pr = "ohm", "feature-x", 1
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/branches/{branch}/pr-draft"
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/branches/{branch}/open-pr",
|
||||
json={"title": "t", "description": "d"},
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/prs/{pr}/seen",
|
||||
json={"last_seen_message_id": 1},
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/prs/{pr}/review",
|
||||
json={"text": "x", "anchor_payload": {}},
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/prs/{pr}/merge"
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/prs/{pr}/withdraw"
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/prs/{pr}/description",
|
||||
json={"title": "t", "description": "d"},
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
f"/api/rfcs/{slug}/prs/{pr}/resolution-branch"
|
||||
).status_code == 401
|
||||
)
|
||||
|
||||
|
||||
def test_anonymous_discussion_writes_refused(app_with_fake_gitea):
|
||||
"""The v0.5.0 PR-less discussion surface — write gates must hold.
|
||||
This duplicates the assertion in `test_discussion_vertical.py` and
|
||||
keeps it here too as the canonical home for the item #4 audit."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
client.cookies.clear()
|
||||
assert (
|
||||
client.post(
|
||||
"/api/rfcs/ohm/discussion/threads",
|
||||
json={"message": "drive-by"},
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
"/api/rfcs/ohm/discussion/threads/1/messages",
|
||||
json={"text": "drive-by"},
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
"/api/rfcs/ohm/discussion/threads/1/resolve"
|
||||
).status_code == 401
|
||||
)
|
||||
|
||||
|
||||
def test_anonymous_admin_writes_refused(app_with_fake_gitea):
|
||||
"""Admin surfaces — role, mute, allowlist — refuse anonymous.
|
||||
The auth check runs before the require_admin role check, so the
|
||||
response is 401."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
client.cookies.clear()
|
||||
assert (
|
||||
client.post(
|
||||
"/api/admin/users/1/role", json={"role": "admin"}
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
"/api/admin/users/1/mute", json={"muted": True}
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
"/api/admin/allowlist", json={"email": "x@y.z"}
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.delete("/api/admin/allowlist/x@y.z").status_code == 401
|
||||
)
|
||||
# Admin reads also gated.
|
||||
assert client.get("/api/admin/users").status_code == 401
|
||||
assert client.get("/api/admin/audit").status_code == 401
|
||||
assert client.get("/api/admin/permission-events").status_code == 401
|
||||
assert client.get("/api/admin/graduation-queue").status_code == 401
|
||||
assert client.get("/api/admin/allowlist").status_code == 401
|
||||
|
||||
|
||||
def test_anonymous_notification_writes_refused(app_with_fake_gitea):
|
||||
"""Notification preference / watch / mark-read / user-mute writes —
|
||||
all per-user surfaces, all require an authenticated viewer."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
client.cookies.clear()
|
||||
assert (
|
||||
client.post(
|
||||
"/api/users/me/notification-preferences",
|
||||
json={"email_personal_direct": False},
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post(
|
||||
"/api/users/me/quiet-hours",
|
||||
json={"start": None, "end": None, "timezone": None},
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post("/api/rfcs/ohm/watch", json={"state": "watching"}).status_code
|
||||
== 401
|
||||
)
|
||||
assert client.post("/api/notifications/1/read").status_code == 401
|
||||
assert (
|
||||
client.post("/api/notifications/read", json={}).status_code == 401
|
||||
)
|
||||
assert client.post("/api/users/1/notification-mute").status_code == 401
|
||||
assert client.delete("/api/users/1/notification-mute").status_code == 401
|
||||
|
||||
|
||||
def test_anonymous_funder_writes_refused(app_with_fake_gitea):
|
||||
"""§6.7 funder credential + consent writes — registering a key,
|
||||
consenting to fund — all refuse anonymous callers."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
client.cookies.clear()
|
||||
assert (
|
||||
client.post(
|
||||
"/api/users/me/funder/credentials",
|
||||
json={"provider": "anthropic", "api_key": "sk-test"},
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.delete(
|
||||
"/api/users/me/funder/credentials/anthropic"
|
||||
).status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.post("/api/rfcs/ohm/funder/consent").status_code == 401
|
||||
)
|
||||
assert (
|
||||
client.delete("/api/rfcs/ohm/funder/consent").status_code == 401
|
||||
)
|
||||
|
||||
|
||||
def test_anonymous_graduation_writes_refused(app_with_fake_gitea):
|
||||
"""§13 graduation: the POST kickoff and POST claim both refuse
|
||||
anonymous. The progress SSE was gated to require_user in v0.6.0
|
||||
(item #4) since it surfaces admin-internal step detail."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
client.cookies.clear()
|
||||
assert (
|
||||
client.post(
|
||||
"/api/rfcs/anything/graduate",
|
||||
json={
|
||||
"rfc_id": "RFC-0001",
|
||||
"repo_name": "rfc-0001-x",
|
||||
"owners": ["alice"],
|
||||
},
|
||||
).status_code == 401
|
||||
)
|
||||
assert client.post("/api/rfcs/anything/claim").status_code == 401
|
||||
# v0.6.0 tightening: progress SSE now requires require_user.
|
||||
# No graduation is in flight, but the auth check runs first.
|
||||
assert (
|
||||
client.get("/api/rfcs/anything/graduate/progress").status_code == 401
|
||||
)
|
||||
|
||||
|
||||
def test_anonymous_can_read_published_pr_view(app_with_fake_gitea):
|
||||
"""The PR review page is §11.3 universal-public — once a PR is
|
||||
open, anonymous viewers can read it. This guards against a
|
||||
regression where the read endpoint accidentally grows an auth
|
||||
gate."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
# Seed an open PR row directly — the cache shape is enough for
|
||||
# the read endpoint; the live Gitea fetch falls back gracefully.
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO cached_prs
|
||||
(rfc_slug, pr_kind, repo, pr_number, title, description, state,
|
||||
opened_by, opened_at, head_branch, base_branch, head_sha)
|
||||
VALUES ('ohm', 'rfc_branch', 'wiggleverse/rfc-0001-ohm', 7, 't', 'd',
|
||||
'open', 'alice', datetime('now'), 'feature-x', 'main', 'sha7')
|
||||
"""
|
||||
)
|
||||
client.cookies.clear()
|
||||
# Anonymous read on an open PR: should be 200. The endpoint may
|
||||
# surface a partial response (the FakeGitea won't have the head
|
||||
# branch's RFC.md, so branch_body falls back to empty) but the
|
||||
# auth gate must let the read through.
|
||||
r = client.get("/api/rfcs/ohm/prs/7")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["capabilities"]["is_anonymous"] is True
|
||||
assert body["capabilities"]["can_merge"] is False
|
||||
assert body["capabilities"]["can_post_review"] is False
|
||||
@@ -0,0 +1,237 @@
|
||||
"""End-to-end integration tests for the v0.5.0 PR-less discussion
|
||||
surface — roadmap item #3, "discussion without PR; contribution requires
|
||||
PR."
|
||||
|
||||
The vertical: an active RFC exists; the discussion endpoints under
|
||||
`/api/rfcs/<slug>/discussion/...` open threads with
|
||||
`threads.branch_name IS NULL`, post messages into them, and surface
|
||||
them on subsequent reads. Branch-scoped threads (the §8.12 surface)
|
||||
remain segregated. Anonymous viewers can read; only signed-in
|
||||
contributors can write.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
# Reuse the harness from Slice 1 / Slice 2.
|
||||
from test_propose_vertical import ( # noqa: F401 — fixtures land via import
|
||||
FakeGitea,
|
||||
app_with_fake_gitea,
|
||||
provision_user_row,
|
||||
sign_in_as,
|
||||
tmp_env,
|
||||
)
|
||||
from test_rfc_view_vertical import seed_active_rfc, SEED_BODY
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_create_and_post_to_pr_less_discussion_thread(app_with_fake_gitea):
|
||||
"""The vertical: signed-in contributor opens a thread on the RFC's
|
||||
discussion surface, posts a message, and the thread + message
|
||||
surface on subsequent reads with branch_name IS NULL."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="alice", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
sign_in_as(client, user_id=1, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
|
||||
# Listing materializes the default whole-doc thread.
|
||||
r = client.get("/api/rfcs/ohm/discussion/threads")
|
||||
assert r.status_code == 200, r.text
|
||||
items = r.json()["items"]
|
||||
assert len(items) == 1
|
||||
default_thread_id = items[0]["id"]
|
||||
assert items[0]["anchor_kind"] == "whole-doc"
|
||||
assert items[0]["thread_kind"] == "chat"
|
||||
|
||||
# Open an additional discussion thread with a first message.
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/discussion/threads",
|
||||
json={"label": "Question about §3", "message": "Is consent baked into the trait model?"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
payload = r.json()
|
||||
thread_id = payload["thread_id"]
|
||||
message_id = payload["message_id"]
|
||||
assert thread_id is not None and message_id is not None
|
||||
|
||||
# Confirm the row carries branch_name IS NULL (the PR-less shape).
|
||||
row = db.conn().execute(
|
||||
"SELECT rfc_slug, branch_name, thread_kind, anchor_kind, created_by FROM threads WHERE id = ?",
|
||||
(thread_id,),
|
||||
).fetchone()
|
||||
assert row["rfc_slug"] == "ohm"
|
||||
assert row["branch_name"] is None
|
||||
assert row["thread_kind"] == "chat"
|
||||
assert row["anchor_kind"] == "whole-doc"
|
||||
assert row["created_by"] == 1
|
||||
|
||||
# The thread surfaces on the list endpoint alongside the default.
|
||||
r = client.get("/api/rfcs/ohm/discussion/threads")
|
||||
ids = [t["id"] for t in r.json()["items"]]
|
||||
assert default_thread_id in ids
|
||||
assert thread_id in ids
|
||||
|
||||
# Posting a reply on the new thread persists and returns the id.
|
||||
r = client.post(
|
||||
f"/api/rfcs/ohm/discussion/threads/{thread_id}/messages",
|
||||
json={"text": "Following up — see §3.2."},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
reply_id = r.json()["message_id"]
|
||||
|
||||
# The messages read endpoint returns both messages in order.
|
||||
r = client.get(f"/api/rfcs/ohm/discussion/threads/{thread_id}/messages")
|
||||
assert r.status_code == 200
|
||||
messages = r.json()["messages"]
|
||||
assert [m["id"] for m in messages] == [message_id, reply_id]
|
||||
assert messages[0]["author_login"] == "alice"
|
||||
assert messages[0]["text"].startswith("Is consent")
|
||||
|
||||
|
||||
def test_anonymous_can_read_but_cannot_post_discussion(app_with_fake_gitea):
|
||||
"""Per the v0.3.0 anonymous-read contract: reads on the discussion
|
||||
surface are open; write attempts return 401. v0.6.0 (item #4) will
|
||||
tighten the read gate — v0.5.0 holds the write line so there is no
|
||||
open window between releases."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
|
||||
# Seed the discussion thread + first message as Alice.
|
||||
sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/discussion/threads",
|
||||
json={"message": "First."},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
thread_id = r.json()["thread_id"]
|
||||
|
||||
# Drop the session — viewer is anonymous now.
|
||||
client.cookies.clear()
|
||||
|
||||
# Reads are open.
|
||||
r = client.get("/api/rfcs/ohm/discussion/threads")
|
||||
assert r.status_code == 200
|
||||
assert any(t["id"] == thread_id for t in r.json()["items"])
|
||||
|
||||
r = client.get(f"/api/rfcs/ohm/discussion/threads/{thread_id}/messages")
|
||||
assert r.status_code == 200
|
||||
assert len(r.json()["messages"]) >= 1
|
||||
|
||||
# Writes refuse 401.
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/discussion/threads",
|
||||
json={"message": "Drive-by."},
|
||||
)
|
||||
assert r.status_code == 401
|
||||
|
||||
r = client.post(
|
||||
f"/api/rfcs/ohm/discussion/threads/{thread_id}/messages",
|
||||
json={"text": "Drive-by reply."},
|
||||
)
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_discussion_threads_and_branch_threads_are_segregated(app_with_fake_gitea):
|
||||
"""A branch-scoped thread (the §8.12 surface, branch_name='main' or a
|
||||
feature branch) MUST NOT surface on the discussion endpoint, which
|
||||
is keyed on branch_name IS NULL. The two surfaces share a table; the
|
||||
null-filter is what segregates them."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=3, login="alice", role="contributor")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
sign_in_as(client, user_id=3, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
|
||||
# Manually materialize a branch-scoped thread on a feature branch.
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO threads
|
||||
(rfc_slug, branch_name, anchor_kind, thread_kind, label, created_by)
|
||||
VALUES ('ohm', 'alice-draft-aa00', 'whole-doc', 'chat', NULL, 3)
|
||||
"""
|
||||
)
|
||||
# And one on the discussion surface.
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/discussion/threads",
|
||||
json={"message": "Discussion-surface message."},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
discussion_thread_id = r.json()["thread_id"]
|
||||
|
||||
# The discussion list contains the null-branch thread (plus the
|
||||
# default whole-doc) and excludes the feature-branch thread.
|
||||
r = client.get("/api/rfcs/ohm/discussion/threads")
|
||||
assert r.status_code == 200
|
||||
ids = [t["id"] for t in r.json()["items"]]
|
||||
assert discussion_thread_id in ids
|
||||
# Feature-branch thread MUST NOT surface.
|
||||
branch_thread_row = db.conn().execute(
|
||||
"SELECT id FROM threads WHERE branch_name = 'alice-draft-aa00'"
|
||||
).fetchone()
|
||||
assert branch_thread_row is not None
|
||||
assert branch_thread_row["id"] not in ids
|
||||
|
||||
|
||||
def test_discussion_thread_resolve_permissions(app_with_fake_gitea):
|
||||
"""A thread's creator can resolve it; an unrelated contributor cannot;
|
||||
an admin / owner / RFC-owner can. Mirrors §8.12's resolution rule for
|
||||
branch-scoped threads."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=4, login="alice", role="contributor")
|
||||
provision_user_row(user_id=5, login="bob", role="contributor")
|
||||
provision_user_row(user_id=6, login="ben", role="owner")
|
||||
seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY)
|
||||
|
||||
sign_in_as(client, user_id=4, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/discussion/threads",
|
||||
json={"label": "Alice's thread", "message": "..."},
|
||||
)
|
||||
thread_id = r.json()["thread_id"]
|
||||
|
||||
# Unrelated contributor refused.
|
||||
sign_in_as(client, user_id=5, gitea_login="bob", display_name="Bob", role="contributor")
|
||||
r = client.post(f"/api/rfcs/ohm/discussion/threads/{thread_id}/resolve")
|
||||
assert r.status_code == 403
|
||||
|
||||
# Creator allowed.
|
||||
sign_in_as(client, user_id=4, gitea_login="alice", display_name="Alice", role="contributor")
|
||||
r = client.post(f"/api/rfcs/ohm/discussion/threads/{thread_id}/resolve")
|
||||
assert r.status_code == 200
|
||||
|
||||
# Open another thread, resolve it as the owner.
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/discussion/threads",
|
||||
json={"label": "Another thread", "message": "..."},
|
||||
)
|
||||
thread_id2 = r.json()["thread_id"]
|
||||
sign_in_as(client, user_id=6, gitea_login="ben", display_name="Ben", role="owner")
|
||||
r = client.post(f"/api/rfcs/ohm/discussion/threads/{thread_id2}/resolve")
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def test_discussion_404_on_unknown_rfc(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
r = client.get("/api/rfcs/nonexistent/discussion/threads")
|
||||
assert r.status_code == 404
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "rfc-app-frontend",
|
||||
"version": "0.2.1",
|
||||
"version": "0.6.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "rfc-app-frontend",
|
||||
"version": "0.2.1",
|
||||
"version": "0.6.0",
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "^6.10.3",
|
||||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "rfc-app-frontend",
|
||||
"private": true,
|
||||
"version": "0.4.0",
|
||||
"version": "0.6.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1776,3 +1776,91 @@
|
||||
.grad-queue-link:hover strong { text-decoration: underline; }
|
||||
.muted { color: #6b7280; }
|
||||
.error { color: #b91c1c; }
|
||||
|
||||
/* v0.5.0 — PR-less per-RFC discussion panel (RFCDiscussionPanel.jsx).
|
||||
Visual neighbor of .chat-panel but distinct: discussion lives on the
|
||||
RFC, branch chat lives on the branch. Same flex column shape so it
|
||||
slots cleanly into the existing .right-panel container.
|
||||
*/
|
||||
.discussion-panel {
|
||||
flex: 1; display: flex; flex-direction: column;
|
||||
overflow: hidden; min-height: 0;
|
||||
}
|
||||
.discussion-header {
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid #f0f0ee;
|
||||
background: #fafafa;
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
}
|
||||
.discussion-header-title { font-size: 12px; color: #555; font-weight: 600; }
|
||||
.discussion-header-meta { font-size: 11px; color: #888; }
|
||||
.discussion-thread-tabs {
|
||||
display: flex; gap: 4px; flex-wrap: wrap;
|
||||
padding: 6px 14px;
|
||||
border-bottom: 1px solid #f0f0ee;
|
||||
background: #fcfcfb;
|
||||
}
|
||||
.discussion-thread-tab {
|
||||
background: #fff; border: 1px solid #e5e5e0; cursor: pointer;
|
||||
font-size: 11px; color: #555;
|
||||
padding: 3px 8px; border-radius: 999px;
|
||||
}
|
||||
.discussion-thread-tab.active {
|
||||
background: #eef2ff; border-color: #5b5bd6; color: #3737a0;
|
||||
}
|
||||
.discussion-thread-tab.resolved { opacity: 0.6; }
|
||||
.discussion-messages {
|
||||
flex: 1; overflow-y: auto;
|
||||
padding: 14px;
|
||||
display: flex; flex-direction: column; gap: 10px;
|
||||
}
|
||||
.discussion-empty {
|
||||
flex: 1; display: flex; align-items: center; justify-content: center;
|
||||
text-align: center; padding: 24px;
|
||||
}
|
||||
.discussion-empty p {
|
||||
font-size: 13px; color: #999; line-height: 1.6; max-width: 280px;
|
||||
}
|
||||
.discussion-error {
|
||||
background: #fee; border: 1px solid #fcc; color: #b91c1c;
|
||||
padding: 8px 10px; border-radius: 4px; font-size: 12px;
|
||||
}
|
||||
.discussion-message { display: flex; flex-direction: column; gap: 3px; }
|
||||
.discussion-message-meta {
|
||||
display: flex; gap: 8px; font-size: 11px; color: #888;
|
||||
}
|
||||
.discussion-message-author { color: #5b5bd6; font-weight: 500; }
|
||||
.discussion-message-quote {
|
||||
font-size: 11px; color: #666; font-style: italic;
|
||||
border-left: 2px solid #ddd; padding-left: 8px; margin-bottom: 2px;
|
||||
}
|
||||
.discussion-message-body {
|
||||
font-size: 13px; color: #222; line-height: 1.5;
|
||||
white-space: pre-wrap; word-wrap: break-word;
|
||||
background: #f7f7f5; padding: 8px 10px; border-radius: 6px;
|
||||
}
|
||||
.discussion-message.system .discussion-system-bubble {
|
||||
font-size: 12px; color: #888; font-style: italic;
|
||||
text-align: center; padding: 4px 0;
|
||||
}
|
||||
.discussion-composer {
|
||||
border-top: 1px solid #f0f0ee;
|
||||
padding: 10px 14px;
|
||||
background: #fafafa;
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
}
|
||||
.discussion-composer-textarea {
|
||||
width: 100%; resize: vertical; min-height: 60px;
|
||||
font-family: inherit; font-size: 13px;
|
||||
border: 1px solid #ddd; border-radius: 4px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
.discussion-composer-textarea:focus {
|
||||
outline: none; border-color: #5b5bd6;
|
||||
}
|
||||
.discussion-composer-actions {
|
||||
display: flex; gap: 8px; align-items: center; justify-content: flex-end;
|
||||
}
|
||||
.discussion-readonly {
|
||||
font-size: 12px; color: #666; padding: 4px 0;
|
||||
}
|
||||
|
||||
@@ -197,6 +197,52 @@ export async function resolveThread(slug, branch, threadId) {
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
// ── v0.5.0: PR-less per-RFC discussion (§5 / §10) ────────────────────────
|
||||
//
|
||||
// The substrate is `threads.branch_name IS NULL` — the same threads
|
||||
// table the branch chat uses, with a null branch the schema already
|
||||
// supported. Contribution still requires a PR (api_prs / openPR), so
|
||||
// these endpoints are read+write for discussion only.
|
||||
|
||||
export async function listDiscussionThreads(slug) {
|
||||
return jsonOrThrow(await fetch(`/api/rfcs/${slug}/discussion/threads`))
|
||||
}
|
||||
|
||||
export async function createDiscussionThread(slug, { label = null, message = null } = {}) {
|
||||
const res = await fetch(`/api/rfcs/${slug}/discussion/threads`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ label, message }),
|
||||
})
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function getDiscussionThreadMessages(slug, threadId) {
|
||||
return jsonOrThrow(await fetch(
|
||||
`/api/rfcs/${slug}/discussion/threads/${threadId}/messages`,
|
||||
))
|
||||
}
|
||||
|
||||
export async function postDiscussionMessage(slug, threadId, { text, quote = null }) {
|
||||
const res = await fetch(
|
||||
`/api/rfcs/${slug}/discussion/threads/${threadId}/messages`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text, quote }),
|
||||
},
|
||||
)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
export async function resolveDiscussionThread(slug, threadId) {
|
||||
const res = await fetch(
|
||||
`/api/rfcs/${slug}/discussion/threads/${threadId}/resolve`,
|
||||
{ method: 'POST' },
|
||||
)
|
||||
return jsonOrThrow(res)
|
||||
}
|
||||
|
||||
// ── Slice 4: super-draft body editing (§9.5) ─────────────────────────────
|
||||
|
||||
export async function startEditBranch(slug, body = {}) {
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
// RFCDiscussionPanel.jsx — v0.5.0's PR-less per-RFC discussion surface.
|
||||
//
|
||||
// Roadmap item #3: an RFC's main view now has a discussion surface
|
||||
// distinct from PR comments and from branch chat. The substrate is the
|
||||
// existing threads/thread_messages tables — rows with
|
||||
// `threads.branch_name IS NULL` scope to "the RFC, no branch yet."
|
||||
//
|
||||
// Reused as the right-column panel on `branchParam === 'main'`. Branch
|
||||
// chat (ChatPanel.jsx) keeps its existing role for branch-scoped work,
|
||||
// including PRs. Contribution remains gated behind opening a PR —
|
||||
// nothing here writes to the document.
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
createDiscussionThread,
|
||||
getDiscussionThreadMessages,
|
||||
listDiscussionThreads,
|
||||
postDiscussionMessage,
|
||||
resolveDiscussionThread,
|
||||
} from '../api'
|
||||
|
||||
export default function RFCDiscussionPanel({ slug, viewer }) {
|
||||
const [threads, setThreads] = useState([])
|
||||
const [messagesByThread, setMessagesByThread] = useState({})
|
||||
const [composer, setComposer] = useState('')
|
||||
const [activeThreadId, setActiveThreadId] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [sending, setSending] = useState(false)
|
||||
const bottomRef = useRef(null)
|
||||
|
||||
// Pull threads + messages on mount / slug change.
|
||||
useEffect(() => {
|
||||
if (!slug) return
|
||||
let cancelled = false
|
||||
setError(null)
|
||||
setThreads([])
|
||||
setMessagesByThread({})
|
||||
setActiveThreadId(null)
|
||||
listDiscussionThreads(slug)
|
||||
.then(async ({ items }) => {
|
||||
if (cancelled) return
|
||||
setThreads(items || [])
|
||||
// Pre-load messages for each thread. The list is small (per-RFC,
|
||||
// not per-branch) so a fan-out fetch is fine; §19.2 candidate
|
||||
// for paging if a hot RFC accumulates lots of threads.
|
||||
const collected = {}
|
||||
for (const t of items || []) {
|
||||
try {
|
||||
const { messages } = await getDiscussionThreadMessages(slug, t.id)
|
||||
collected[t.id] = messages
|
||||
} catch {
|
||||
collected[t.id] = []
|
||||
}
|
||||
}
|
||||
if (!cancelled) {
|
||||
setMessagesByThread(collected)
|
||||
// Default the active thread to the system's lazy whole-doc
|
||||
// default (the first row with anchor_kind='whole-doc' and
|
||||
// no label) so the composer wires to a real id immediately.
|
||||
const dflt = (items || []).find(
|
||||
t => t.anchor_kind === 'whole-doc' && !t.label,
|
||||
)
|
||||
setActiveThreadId(dflt?.id || items?.[0]?.id || null)
|
||||
}
|
||||
})
|
||||
.catch(err => { if (!cancelled) setError(err.message) })
|
||||
return () => { cancelled = true }
|
||||
}, [slug])
|
||||
|
||||
// Scroll to bottom when messages land in the active thread.
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [activeThreadId, messagesByThread[activeThreadId]?.length])
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
if (!viewer) { window.location.href = '/auth/login'; return }
|
||||
const text = composer.trim()
|
||||
if (!text || sending) return
|
||||
setSending(true)
|
||||
setError(null)
|
||||
try {
|
||||
// If no thread yet, mint one with the message as its first turn.
|
||||
if (!activeThreadId) {
|
||||
const { thread_id, message_id } = await createDiscussionThread(slug, { message: text })
|
||||
// Re-pull authoritative state — the default whole-doc thread
|
||||
// existed pre-this call (the GET creates it lazily), so we
|
||||
// either get the existing default's id back from the new
|
||||
// thread's row or the prior default; either way the list call
|
||||
// is the source of truth.
|
||||
const { items } = await listDiscussionThreads(slug)
|
||||
setThreads(items || [])
|
||||
const { messages } = await getDiscussionThreadMessages(slug, thread_id)
|
||||
setMessagesByThread(prev => ({ ...prev, [thread_id]: messages }))
|
||||
setActiveThreadId(thread_id)
|
||||
void message_id
|
||||
} else {
|
||||
const { message_id } = await postDiscussionMessage(slug, activeThreadId, { text })
|
||||
const { messages } = await getDiscussionThreadMessages(slug, activeThreadId)
|
||||
setMessagesByThread(prev => ({ ...prev, [activeThreadId]: messages }))
|
||||
void message_id
|
||||
}
|
||||
setComposer('')
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}, [composer, sending, viewer, slug, activeThreadId])
|
||||
|
||||
const handleNewThread = useCallback(async () => {
|
||||
if (!viewer) { window.location.href = '/auth/login'; return }
|
||||
setError(null)
|
||||
try {
|
||||
const { thread_id } = await createDiscussionThread(slug, { label: null, message: null })
|
||||
const { items } = await listDiscussionThreads(slug)
|
||||
setThreads(items || [])
|
||||
setActiveThreadId(thread_id)
|
||||
setMessagesByThread(prev => ({ ...prev, [thread_id]: [] }))
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
}
|
||||
}, [viewer, slug])
|
||||
|
||||
const handleResolve = useCallback(async (threadId) => {
|
||||
if (!viewer) return
|
||||
setError(null)
|
||||
try {
|
||||
await resolveDiscussionThread(slug, threadId)
|
||||
const { items } = await listDiscussionThreads(slug)
|
||||
setThreads(items || [])
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
}
|
||||
}, [viewer, slug])
|
||||
|
||||
const onKeyDown = useCallback((e) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}, [handleSend])
|
||||
|
||||
const activeThread = threads.find(t => t.id === activeThreadId) || null
|
||||
const activeMessages = messagesByThread[activeThreadId] || []
|
||||
const openThreads = threads.filter(t => t.state === 'open')
|
||||
|
||||
return (
|
||||
<div className="discussion-panel">
|
||||
<div className="discussion-header">
|
||||
<span className="discussion-header-title">
|
||||
Discussion <span className="beta-chip">Beta</span>
|
||||
</span>
|
||||
<span className="discussion-header-meta">
|
||||
{openThreads.length} open thread{openThreads.length === 1 ? '' : 's'}
|
||||
{' · '}contribution requires a PR
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{threads.length > 1 && (
|
||||
<div className="discussion-thread-tabs">
|
||||
{threads.map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
className={`discussion-thread-tab ${t.id === activeThreadId ? 'active' : ''} ${t.state === 'resolved' ? 'resolved' : ''}`}
|
||||
onClick={() => setActiveThreadId(t.id)}
|
||||
title={t.label || (t.id === activeThreadId ? 'Current thread' : 'Open thread')}
|
||||
>
|
||||
{t.label || (t.anchor_kind === 'whole-doc' && !t.label ? 'General' : `Thread ${t.id}`)}
|
||||
{t.state === 'resolved' && ' ✓'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="discussion-messages">
|
||||
{error && <div className="discussion-error">{error}</div>}
|
||||
{activeMessages.length === 0 && !error && (
|
||||
<div className="discussion-empty">
|
||||
<p>
|
||||
{viewer
|
||||
? 'No discussion yet. Be the first to comment — discussion lives here without opening a PR. To propose an edit, use Start Contributing above.'
|
||||
: 'No discussion yet. Sign in to comment. Discussion lives here without opening a PR; proposed edits still flow through PRs.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{activeMessages.map(msg => (
|
||||
<DiscussionMessage key={msg.id} message={msg} />
|
||||
))}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
<div className="discussion-composer">
|
||||
{viewer ? (
|
||||
<>
|
||||
<textarea
|
||||
className="discussion-composer-textarea"
|
||||
value={composer}
|
||||
onChange={e => setComposer(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder={
|
||||
activeThread?.label
|
||||
? `Reply in "${activeThread.label}" — Cmd/Ctrl+Enter to send`
|
||||
: 'Discuss this RFC — Cmd/Ctrl+Enter to send'
|
||||
}
|
||||
disabled={sending}
|
||||
rows={3}
|
||||
/>
|
||||
<div className="discussion-composer-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary"
|
||||
onClick={handleNewThread}
|
||||
disabled={sending}
|
||||
title="Open a fresh discussion thread on this RFC"
|
||||
>
|
||||
New thread
|
||||
</button>
|
||||
{activeThread
|
||||
&& activeThread.state === 'open'
|
||||
&& (activeThread.created_by === viewer.user_id
|
||||
|| viewer.role === 'owner'
|
||||
|| viewer.role === 'admin') && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-link"
|
||||
onClick={() => handleResolve(activeThread.id)}
|
||||
disabled={sending}
|
||||
title="Mark this discussion thread resolved"
|
||||
>
|
||||
Resolve
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
onClick={handleSend}
|
||||
disabled={sending || !composer.trim()}
|
||||
>
|
||||
{sending ? 'Sending…' : 'Send'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="discussion-readonly">
|
||||
Read-only — <a href="/auth/login">sign in</a> to join the discussion.
|
||||
Discussion is in private <strong>Beta</strong>.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DiscussionMessage({ message }) {
|
||||
const isSystem = message.role === 'system'
|
||||
if (isSystem) {
|
||||
return (
|
||||
<div className="discussion-message system">
|
||||
<div className="discussion-system-bubble">{message.text}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className={`discussion-message ${message.role}`}>
|
||||
<div className="discussion-message-meta">
|
||||
<span className="discussion-message-author">
|
||||
@{message.author_login || '—'}
|
||||
</span>
|
||||
<span className="discussion-message-time">
|
||||
{formatTimestamp(message.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
{message.quote && (
|
||||
<div className="discussion-message-quote">"{message.quote}"</div>
|
||||
)}
|
||||
<div className="discussion-message-body">{message.text}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatTimestamp(ts) {
|
||||
if (!ts) return ''
|
||||
try {
|
||||
const d = new Date(ts + (ts.endsWith('Z') ? '' : 'Z'))
|
||||
return d.toLocaleString()
|
||||
} catch {
|
||||
return ts
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import MarkdownPreview from './MarkdownPreview.jsx'
|
||||
import SelectionTooltip from './SelectionTooltip.jsx'
|
||||
import PromptBar from './PromptBar.jsx'
|
||||
import ChatPanel from './ChatPanel.jsx'
|
||||
import RFCDiscussionPanel from './RFCDiscussionPanel.jsx'
|
||||
import ChangePanel, { diffWords } from './ChangePanel.jsx'
|
||||
import PRModal from './PRModal.jsx'
|
||||
import GraduateDialog from './GraduateDialog.jsx'
|
||||
@@ -759,17 +760,25 @@ export default function RFCView({ viewer }) {
|
||||
data-open={drawerOpen ? 'true' : 'false'}
|
||||
/>
|
||||
<div className={`right-panel${drawerOpen ? ' drawer-open' : ''}`} role="complementary">
|
||||
<ChatPanel
|
||||
messages={messages}
|
||||
threads={branchView.threads || []}
|
||||
changes={changes}
|
||||
branchName={branchParam}
|
||||
isStreaming={isStreaming}
|
||||
contributionMode={mode === 'contribute'}
|
||||
onStartContribution={handleStartContributing}
|
||||
onScrollToChange={setFocusedChangeId}
|
||||
onResolveThread={handleResolveThread}
|
||||
/>
|
||||
{/* v0.5.0 — on main, the right panel is the PR-less discussion
|
||||
* surface (threads.branch_name IS NULL). Branches keep their
|
||||
* existing branch-chat panel; contribution still requires
|
||||
* opening a PR from a branch via the Open PR affordance above. */}
|
||||
{branchParam === 'main' ? (
|
||||
<RFCDiscussionPanel slug={slug} viewer={viewer} />
|
||||
) : (
|
||||
<ChatPanel
|
||||
messages={messages}
|
||||
threads={branchView.threads || []}
|
||||
changes={changes}
|
||||
branchName={branchParam}
|
||||
isStreaming={isStreaming}
|
||||
contributionMode={mode === 'contribute'}
|
||||
onStartContribution={handleStartContributing}
|
||||
onScrollToChange={setFocusedChangeId}
|
||||
onResolveThread={handleResolveThread}
|
||||
/>
|
||||
)}
|
||||
{mode === 'contribute' && (changes.length > 0 || manualPending) && (
|
||||
<ChangePanel
|
||||
changes={changes}
|
||||
|
||||
Reference in New Issue
Block a user