diff --git a/backend/app/api.py b/backend/app/api.py index 4e36942..09a66bc 100644 --- a/backend/app/api.py +++ b/backend/app/api.py @@ -51,6 +51,11 @@ class ProposeBody(BaseModel): slug: str = Field(min_length=1, max_length=80) pitch: str = Field(min_length=1) tags: list[str] = Field(default_factory=list) + # Roadmap #26: optional "What will you be using this RFC for?" — the + # concrete ground-truth use case, distinct from the `pitch`'s abstract + # "why is this needed." Optional (NULL/omitted accepted), no minimum, + # generous cap matching the pitch's free-text body bound. + proposed_use_case: str | None = Field(default=None, max_length=8000) class DeclineBody(BaseModel): @@ -557,12 +562,36 @@ def make_router( ).fetchone() if row is None: raise HTTPException(404, "Not found") - return _serialize_rfc(row) + payload = _serialize_rfc(row) + # Roadmap #26: surface the optional propose-time use case on the + # RFC view. The idea PR closes on merge, but the canonical row in + # `proposed_use_cases` persists; look it up by slug (the latest + # 'rfc'-scope row for this slug). NULL == "left blank". + uc = db.conn().execute( + """ + SELECT use_case FROM proposed_use_cases + WHERE scope = 'rfc' AND rfc_slug = ? + ORDER BY id DESC LIMIT 1 + """, + (slug,), + ).fetchone() + payload["proposed_use_case"] = uc["use_case"] if uc else None + return payload # --------------------------------------------------------------- # §7.3 / §9.3: pending ideas # --------------------------------------------------------------- + def _proposal_use_case(pr_number: int) -> str | None: + """Roadmap #26: read the optional use case for an idea PR from the + canonical side table. Returns None when none was supplied (the + "left blank" sentinel the frontend renders tastefully).""" + row = db.conn().execute( + "SELECT use_case FROM proposed_use_cases WHERE scope = 'rfc' AND pr_number = ?", + (pr_number,), + ).fetchone() + return row["use_case"] if row else None + @router.get("/api/proposals") async def list_proposals() -> dict[str, Any]: rows = db.conn().execute( @@ -582,6 +611,7 @@ def make_router( "description": r["description"], "opened_by": r["opened_by"], "opened_at": r["opened_at"], + "proposed_use_case": _proposal_use_case(r["pr_number"]), } for r in rows ] @@ -630,6 +660,7 @@ def make_router( "opened_at": row["opened_at"], "entry": entry_payload, "affordances": affordances, + "proposed_use_case": _proposal_use_case(pr_number), } # --------------------------------------------------------------- @@ -706,6 +737,26 @@ def make_router( # cache write is idempotent.) await cache.refresh_meta_pulls(config, gitea) + # Roadmap #26: persist the optional use case to the canonical, + # reconcile-proof side table keyed by the idea PR number. NULL/ + # blank simply writes no row (absence == "left blank"). Done after + # the refresh so the cache row exists; the mirror onto cached_prs + # keeps the cache column in parity for any read that uses it. + use_case = (payload.proposed_use_case or "").strip() + if use_case: + db.conn().execute( + """ + INSERT INTO proposed_use_cases (scope, rfc_slug, pr_number, use_case) + VALUES ('rfc', ?, ?, ?) + ON CONFLICT(scope, pr_number) DO UPDATE SET use_case = excluded.use_case + """, + (slug, pr["number"], use_case), + ) + db.conn().execute( + "UPDATE cached_prs SET proposed_use_case = ? WHERE pr_kind = 'idea' AND pr_number = ?", + (use_case, pr["number"]), + ) + return {"pr_number": pr["number"], "slug": slug} # --------------------------------------------------------------- diff --git a/backend/app/api_prs.py b/backend/app/api_prs.py index bb9c105..d15a7c0 100644 --- a/backend/app/api_prs.py +++ b/backend/app/api_prs.py @@ -42,6 +42,11 @@ RFC_FILE_PATH = "RFC.md" class OpenPRBody(BaseModel): title: str = Field(min_length=1, max_length=240) description: str = Field(max_length=8000) + # Roadmap #26: optional "What will you be using this change for?" — + # the concrete ground-truth use case sibling to the required + # "why is this change needed" (the `description`). Optional, generous + # cap matching the description bound. + proposed_use_case: str | None = Field(default=None, max_length=8000) class PRDescriptionBody(BaseModel): @@ -173,6 +178,26 @@ def make_router( raise HTTPException(502, f"Gitea: {e.detail}") await _refresh_after_pr_write(rfc) + + # Roadmap #26: persist the optional use case to the canonical, + # reconcile-proof side table keyed by the PR number. Blank/omitted + # writes no row (absence == "left blank"). The mirror onto + # cached_prs keeps the cache column in parity. + use_case = (body.proposed_use_case or "").strip() + if use_case: + db.conn().execute( + """ + INSERT INTO proposed_use_cases (scope, rfc_slug, pr_number, use_case) + VALUES ('pr', ?, ?, ?) + ON CONFLICT(scope, pr_number) DO UPDATE SET use_case = excluded.use_case + """, + (slug, pr["number"], use_case), + ) + db.conn().execute( + "UPDATE cached_prs SET proposed_use_case = ? WHERE rfc_slug = ? AND pr_number = ?", + (use_case, slug, pr["number"]), + ) + return {"pr_number": pr["number"], "slug": slug, "branch": branch} # ------------------------------------------------------------------- @@ -300,6 +325,7 @@ def make_router( "pr_number": pr_number, "title": pr_row["title"], "description": pr_row["description"], + "proposed_use_case": _pr_use_case(pr_number), "state": pr_row["state"], "opened_by": pr_row["opened_by"], "opened_at": pr_row["opened_at"], @@ -762,6 +788,17 @@ def _can_edit_pr_text(rfc, pr_row, viewer) -> bool: return _can_withdraw(rfc, pr_row, viewer) +def _pr_use_case(pr_number: int) -> str | None: + """Roadmap #26: the optional propose-PR use case from the canonical + side table, or None when the change was opened without one ("left + blank").""" + row = db.conn().execute( + "SELECT use_case FROM proposed_use_cases WHERE scope = 'pr' AND pr_number = ?", + (pr_number,), + ).fetchone() + return row["use_case"] if row else None + + def _pr_capabilities(rfc, pr_row, viewer) -> dict: return { "can_merge": _can_merge(rfc, viewer) and pr_row["state"] == "open", diff --git a/backend/migrations/021_proposed_use_case.sql b/backend/migrations/021_proposed_use_case.sql new file mode 100644 index 0000000..fe540c2 --- /dev/null +++ b/backend/migrations/021_proposed_use_case.sql @@ -0,0 +1,47 @@ +-- Roadmap #26 (rfc-app v0.22.0): the optional "What will you be using +-- this for?" capture on the two propose surfaces. +-- +-- The roadmap's framing names "the rfcs table" and "the PR-metadata +-- table" for a `proposed_use_case TEXT NULL` column. In this deployment +-- those two surfaces are the cache tables `cached_rfcs` and `cached_prs` +-- (002_cache.sql). We add the nullable column to each, matching the +-- existing naming convention (no NOT NULL, no default — NULL is the +-- "left blank" sentinel the view surfaces render tastefully). +-- +-- BUT: those tables are *cache*, rebuilt from Gitea by the §4.1 +-- reconciler (cache.py). The reconciler's INSERT...ON CONFLICT DO UPDATE +-- sets only the columns it knows about, so an unlisted column is +-- preserved on the update path — yet a propose/open never *writes* the +-- column through the cache (the write path is endpoint -> Gitea -> +-- reconcile, and the reconciler does not carry this field). So the cache +-- column alone would always read NULL. +-- +-- The durable home is therefore a dedicated app-truth table the propose +-- /open endpoints write directly (keyed by the PR number, which is the +-- stable identity for both idea PRs and rfc_branch PRs) and the view +-- endpoints read back. This is not cache — it is canonical and survives +-- any reconcile. The cache columns are added too for parity with the +-- roadmap's literal shape and for any future reconciler that learns to +-- carry the field, but the side table is the source of truth read at +-- view time. + +ALTER TABLE cached_rfcs ADD COLUMN proposed_use_case TEXT; +ALTER TABLE cached_prs ADD COLUMN proposed_use_case TEXT; + +-- Canonical, reconcile-proof store. One row per propose/open that +-- supplied a use case. `scope` distinguishes the propose-RFC surface +-- ('rfc') from the propose-PR-against-an-RFC surface ('pr'); `pr_number` +-- is the join key the endpoints already have in hand. NULL/omitted use +-- cases simply never write a row here, so absence == "left blank". +CREATE TABLE proposed_use_cases ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + scope TEXT NOT NULL CHECK (scope IN ('rfc', 'pr')), + rfc_slug TEXT NOT NULL, + pr_number INTEGER NOT NULL, + use_case TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (scope, pr_number) +); + +CREATE INDEX idx_proposed_use_cases_lookup ON proposed_use_cases (scope, pr_number); +CREATE INDEX idx_proposed_use_cases_slug ON proposed_use_cases (scope, rfc_slug); diff --git a/backend/tests/test_proposed_use_case_vertical.py b/backend/tests/test_proposed_use_case_vertical.py new file mode 100644 index 0000000..edfd623 --- /dev/null +++ b/backend/tests/test_proposed_use_case_vertical.py @@ -0,0 +1,161 @@ +"""End-to-end vertical for roadmap #26 (rfc-app v0.22.0): the optional +"What will you be using this for?" capture on the two propose surfaces. + +Reuses the FakeGitea + session helpers from test_propose_vertical.py and +the active-RFC seed from test_rfc_view_vertical.py. Proves: + + (a) propose-RFC persists and returns `proposed_use_case` when supplied, + and the value survives onto the merged super-draft's RFC view; + (b) propose-RFC accepts a NULL / omitted use case ("left blank"); + (c) propose-PR persists and returns `proposed_use_case` when supplied, + and accepts a NULL / omitted one. +""" +from __future__ import annotations + +from test_propose_vertical import ( # noqa: F401 + FakeGitea, + app_with_fake_gitea, + provision_user_row, + sign_in_as, + tmp_env, +) +from test_pr_flow_vertical import _cut_branch_and_accept_change +from test_rfc_view_vertical import SEED_BODY, seed_active_rfc + + +# --------------------------------------------------------------------------- +# propose-RFC +# --------------------------------------------------------------------------- + + +def test_propose_rfc_persists_and_returns_use_case(app_with_fake_gitea): + 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") + provision_user_row(user_id=1, login="ben", role="owner") + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test") + + r = client.post("/api/rfcs/propose", json={ + "title": "Open Human Model", + "slug": "open-human-model", + "pitch": "A shared definition of what we mean by *human*.", + "tags": ["identity"], + "proposed_use_case": "Wiring OHM into the OpenXML consent surface.", + }) + assert r.status_code == 200, r.text + pr_number = r.json()["pr_number"] + + # The pending-idea list carries the use case. + items = client.get("/api/proposals").json()["items"] + assert items[0]["proposed_use_case"] == "Wiring OHM into the OpenXML consent surface." + + # The pending-idea detail view carries it too. + proposal = client.get(f"/api/proposals/{pr_number}").json() + assert proposal["proposed_use_case"] == "Wiring OHM into the OpenXML consent surface." + + # Merge as owner; the use case survives onto the RFC view (looked + # up by slug from the canonical side table, since the idea PR + # closes on merge). + sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner", email="ben@test") + r = client.post(f"/api/proposals/{pr_number}/merge") + assert r.status_code == 200, r.text + + view = client.get("/api/rfcs/open-human-model").json() + assert view["proposed_use_case"] == "Wiring OHM into the OpenXML consent surface." + + +def test_propose_rfc_use_case_optional(app_with_fake_gitea): + from fastapi.testclient import TestClient + + app, _fake = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=3, login="carol", role="contributor") + sign_in_as(client, user_id=3, gitea_login="carol", display_name="Carol", role="contributor") + + # Omitted entirely. + r = client.post("/api/rfcs/propose", json={ + "title": "No Use Case", "slug": "no-use-case", "pitch": "p", "tags": [], + }) + assert r.status_code == 200, r.text + pr_a = r.json()["pr_number"] + + # Explicit null. + r = client.post("/api/rfcs/propose", json={ + "title": "Null Use Case", "slug": "null-use-case", "pitch": "p", + "tags": [], "proposed_use_case": None, + }) + assert r.status_code == 200, r.text + pr_b = r.json()["pr_number"] + + # Blank/whitespace — treated as "left blank", no row written. + r = client.post("/api/rfcs/propose", json={ + "title": "Blank Use Case", "slug": "blank-use-case", "pitch": "p", + "tags": [], "proposed_use_case": " ", + }) + assert r.status_code == 200, r.text + pr_c = r.json()["pr_number"] + + for pr in (pr_a, pr_b, pr_c): + assert client.get(f"/api/proposals/{pr}").json()["proposed_use_case"] is None + + +# --------------------------------------------------------------------------- +# propose-PR (against an active RFC) +# --------------------------------------------------------------------------- + + +def test_propose_pr_persists_and_returns_use_case(app_with_fake_gitea): + 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) + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") + + branch, _ = _cut_branch_and_accept_change( + client, fake, slug="ohm", + original="Open Human Model is a framework for representing humans.", + proposed="Open Human Model is a framework for representing humans across systems.", + ) + r = client.post( + f"/api/rfcs/ohm/branches/{branch}/open-pr", + json={ + "title": "Tighten the opening", + "description": "Scope to systems.", + "proposed_use_case": "Building a cross-system consent registry.", + }, + ) + assert r.status_code == 200, r.text + pr_number = r.json()["pr_number"] + + pr = client.get(f"/api/rfcs/ohm/prs/{pr_number}").json() + assert pr["proposed_use_case"] == "Building a cross-system consent registry." + + +def test_propose_pr_use_case_optional(app_with_fake_gitea): + 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) + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") + + branch, _ = _cut_branch_and_accept_change( + client, fake, slug="ohm", + original="It defines consent, trait, and agency in compatible terms.", + proposed="It defines consent, trait, harm, and agency in compatible terms.", + ) + # No proposed_use_case key at all. + r = client.post( + f"/api/rfcs/ohm/branches/{branch}/open-pr", + json={"title": "Add harm", "description": "Name harm explicitly."}, + ) + assert r.status_code == 200, r.text + pr_number = r.json()["pr_number"] + + pr = client.get(f"/api/rfcs/ohm/prs/{pr_number}").json() + assert pr["proposed_use_case"] is None diff --git a/frontend/src/api.js b/frontend/src/api.js index b5d9cb4..27e6881 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -166,11 +166,19 @@ export async function getProposal(prNumber) { return jsonOrThrow(await fetch(`/api/proposals/${prNumber}`)) } -export async function proposeRFC({ title, slug, pitch, tags }) { +export async function proposeRFC({ title, slug, pitch, tags, proposedUseCase }) { const res = await fetch('/api/rfcs/propose', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ title, slug, pitch, tags: tags || [] }), + // #26: proposed_use_case is optional; send null when blank so the + // backend treats it as "left blank". + body: JSON.stringify({ + title, + slug, + pitch, + tags: tags || [], + proposed_use_case: proposedUseCase || null, + }), }) return jsonOrThrow(res) } @@ -492,13 +500,14 @@ export async function draftPRText(slug, branch) { return jsonOrThrow(res) } -export async function openPR(slug, branch, { title, description }) { +export async function openPR(slug, branch, { title, description, proposedUseCase }) { const res = await fetch( `/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/open-pr`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ title, description }), + // #26: proposed_use_case is optional; null when blank. + body: JSON.stringify({ title, description, proposed_use_case: proposedUseCase || null }), }, ) return jsonOrThrow(res) diff --git a/frontend/src/components/PRModal.jsx b/frontend/src/components/PRModal.jsx index 23ba78c..1506268 100644 --- a/frontend/src/components/PRModal.jsx +++ b/frontend/src/components/PRModal.jsx @@ -15,6 +15,8 @@ import { EVENTS, track } from '../lib/analytics' export default function PRModal({ slug, branch, branchIsPrivate, onClose, onOpened }) { const [title, setTitle] = useState('') const [description, setDescription] = useState('') + // #26: optional ground-truth use case for this change. + const [useCase, setUseCase] = useState('') const [drafting, setDrafting] = useState(true) const [submitting, setSubmitting] = useState(false) const [confirmed, setConfirmed] = useState(!branchIsPrivate) @@ -39,7 +41,11 @@ export default function PRModal({ slug, branch, branchIsPrivate, onClose, onOpen setSubmitting(true) setError(null) try { - const { pr_number } = await openPR(slug, branch, { title: title.trim(), description: description.trim() }) + const { pr_number } = await openPR(slug, branch, { + title: title.trim(), + description: description.trim(), + proposedUseCase: useCase.trim() || null, + }) // v0.15.0 — analytics: fire on §10.2 PR-open success. slug // and pr_number are the join keys; title/description stay out. track(EVENTS.PR_OPENED, { rfc_slug: slug, pr_number }) @@ -104,6 +110,20 @@ export default function PRModal({ slug, branch, branchIsPrivate, onClose, onOpen what was argued, what shifted, what the arbiters are asked to consider.
+ +