#26: optional proposed-use-case field on propose-RFC + propose-PR
Adds an optional "What will you be using this for?" capture as a sibling to the required justification on both propose surfaces, per roadmap #26. - propose-RFC modal (ProposeModal): optional textarea below the required "Why is this RFC needed?" pitch, labeled "What will you be using this RFC for? (optional)". - propose-PR modal (PRModal): optional textarea below the required description, labeled "What will you be using this change for?". - Backend: ProposeBody / OpenPRBody gain an optional `proposed_use_case` (NULL/omitted accepted, no min, 8000-char cap matching the existing free-text bound). Persisted to a new canonical side table `proposed_use_cases` keyed by PR number, mirrored onto the cache columns added by migration 021. Returned on the proposal list/detail, RFC detail (by slug), and PR detail endpoints. - Display: ProposalView, RFCView (main only), and PRView render the captured use case with a muted "left blank" treatment when NULL. - migration 021: nullable `proposed_use_case` on cached_rfcs/cached_prs plus the reconcile-proof `proposed_use_cases` truth table. - New vertical test_proposed_use_case_vertical: persists+returns when supplied, accepted as NULL/omitted, for both surfaces. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+52
-1
@@ -51,6 +51,11 @@ class ProposeBody(BaseModel):
|
|||||||
slug: str = Field(min_length=1, max_length=80)
|
slug: str = Field(min_length=1, max_length=80)
|
||||||
pitch: str = Field(min_length=1)
|
pitch: str = Field(min_length=1)
|
||||||
tags: list[str] = Field(default_factory=list)
|
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):
|
class DeclineBody(BaseModel):
|
||||||
@@ -557,12 +562,36 @@ def make_router(
|
|||||||
).fetchone()
|
).fetchone()
|
||||||
if row is None:
|
if row is None:
|
||||||
raise HTTPException(404, "Not found")
|
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
|
# §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")
|
@router.get("/api/proposals")
|
||||||
async def list_proposals() -> dict[str, Any]:
|
async def list_proposals() -> dict[str, Any]:
|
||||||
rows = db.conn().execute(
|
rows = db.conn().execute(
|
||||||
@@ -582,6 +611,7 @@ def make_router(
|
|||||||
"description": r["description"],
|
"description": r["description"],
|
||||||
"opened_by": r["opened_by"],
|
"opened_by": r["opened_by"],
|
||||||
"opened_at": r["opened_at"],
|
"opened_at": r["opened_at"],
|
||||||
|
"proposed_use_case": _proposal_use_case(r["pr_number"]),
|
||||||
}
|
}
|
||||||
for r in rows
|
for r in rows
|
||||||
]
|
]
|
||||||
@@ -630,6 +660,7 @@ def make_router(
|
|||||||
"opened_at": row["opened_at"],
|
"opened_at": row["opened_at"],
|
||||||
"entry": entry_payload,
|
"entry": entry_payload,
|
||||||
"affordances": affordances,
|
"affordances": affordances,
|
||||||
|
"proposed_use_case": _proposal_use_case(pr_number),
|
||||||
}
|
}
|
||||||
|
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
@@ -706,6 +737,26 @@ def make_router(
|
|||||||
# cache write is idempotent.)
|
# cache write is idempotent.)
|
||||||
await cache.refresh_meta_pulls(config, gitea)
|
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}
|
return {"pr_number": pr["number"], "slug": slug}
|
||||||
|
|
||||||
# ---------------------------------------------------------------
|
# ---------------------------------------------------------------
|
||||||
|
|||||||
@@ -42,6 +42,11 @@ RFC_FILE_PATH = "RFC.md"
|
|||||||
class OpenPRBody(BaseModel):
|
class OpenPRBody(BaseModel):
|
||||||
title: str = Field(min_length=1, max_length=240)
|
title: str = Field(min_length=1, max_length=240)
|
||||||
description: str = Field(max_length=8000)
|
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):
|
class PRDescriptionBody(BaseModel):
|
||||||
@@ -173,6 +178,26 @@ def make_router(
|
|||||||
raise HTTPException(502, f"Gitea: {e.detail}")
|
raise HTTPException(502, f"Gitea: {e.detail}")
|
||||||
|
|
||||||
await _refresh_after_pr_write(rfc)
|
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}
|
return {"pr_number": pr["number"], "slug": slug, "branch": branch}
|
||||||
|
|
||||||
# -------------------------------------------------------------------
|
# -------------------------------------------------------------------
|
||||||
@@ -300,6 +325,7 @@ def make_router(
|
|||||||
"pr_number": pr_number,
|
"pr_number": pr_number,
|
||||||
"title": pr_row["title"],
|
"title": pr_row["title"],
|
||||||
"description": pr_row["description"],
|
"description": pr_row["description"],
|
||||||
|
"proposed_use_case": _pr_use_case(pr_number),
|
||||||
"state": pr_row["state"],
|
"state": pr_row["state"],
|
||||||
"opened_by": pr_row["opened_by"],
|
"opened_by": pr_row["opened_by"],
|
||||||
"opened_at": pr_row["opened_at"],
|
"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)
|
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:
|
def _pr_capabilities(rfc, pr_row, viewer) -> dict:
|
||||||
return {
|
return {
|
||||||
"can_merge": _can_merge(rfc, viewer) and pr_row["state"] == "open",
|
"can_merge": _can_merge(rfc, viewer) and pr_row["state"] == "open",
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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
|
||||||
+13
-4
@@ -166,11 +166,19 @@ export async function getProposal(prNumber) {
|
|||||||
return jsonOrThrow(await fetch(`/api/proposals/${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', {
|
const res = await fetch('/api/rfcs/propose', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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)
|
return jsonOrThrow(res)
|
||||||
}
|
}
|
||||||
@@ -492,13 +500,14 @@ export async function draftPRText(slug, branch) {
|
|||||||
return jsonOrThrow(res)
|
return jsonOrThrow(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openPR(slug, branch, { title, description }) {
|
export async function openPR(slug, branch, { title, description, proposedUseCase }) {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/open-pr`,
|
`/api/rfcs/${slug}/branches/${encodeURIComponent(branch)}/open-pr`,
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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)
|
return jsonOrThrow(res)
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import { EVENTS, track } from '../lib/analytics'
|
|||||||
export default function PRModal({ slug, branch, branchIsPrivate, onClose, onOpened }) {
|
export default function PRModal({ slug, branch, branchIsPrivate, onClose, onOpened }) {
|
||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
const [description, setDescription] = useState('')
|
const [description, setDescription] = useState('')
|
||||||
|
// #26: optional ground-truth use case for this change.
|
||||||
|
const [useCase, setUseCase] = useState('')
|
||||||
const [drafting, setDrafting] = useState(true)
|
const [drafting, setDrafting] = useState(true)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [confirmed, setConfirmed] = useState(!branchIsPrivate)
|
const [confirmed, setConfirmed] = useState(!branchIsPrivate)
|
||||||
@@ -39,7 +41,11 @@ export default function PRModal({ slug, branch, branchIsPrivate, onClose, onOpen
|
|||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
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
|
// v0.15.0 — analytics: fire on §10.2 PR-open success. slug
|
||||||
// and pr_number are the join keys; title/description stay out.
|
// and pr_number are the join keys; title/description stay out.
|
||||||
track(EVENTS.PR_OPENED, { rfc_slug: slug, pr_number })
|
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
|
what was argued, what shifted, what the arbiters are asked
|
||||||
to consider.
|
to consider.
|
||||||
</p>
|
</p>
|
||||||
|
<label className="modal-label">What will you be using this change for? (optional)</label>
|
||||||
|
<textarea
|
||||||
|
className="modal-textarea"
|
||||||
|
value={useCase}
|
||||||
|
onChange={e => setUseCase(e.target.value)}
|
||||||
|
placeholder="The concrete thing this change unlocks for you. Optional."
|
||||||
|
disabled={drafting || submitting}
|
||||||
|
rows={3}
|
||||||
|
maxLength={8000}
|
||||||
|
/>
|
||||||
|
<p className="field-help">
|
||||||
|
#26: the concrete ground-truth use case — distinct from "why
|
||||||
|
it's needed" above. Leave blank if you'd rather not say.
|
||||||
|
</p>
|
||||||
{error && <p className="field-error">{error}</p>}
|
{error && <p className="field-error">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-actions">
|
<div className="modal-actions">
|
||||||
|
|||||||
@@ -220,6 +220,17 @@ export default function PRView({ viewer }) {
|
|||||||
{pr.description && (
|
{pr.description && (
|
||||||
<p className="pr-description">{pr.description}</p>
|
<p className="pr-description">{pr.description}</p>
|
||||||
)}
|
)}
|
||||||
|
{/* #26: the optional ground-truth use case for this change,
|
||||||
|
captured when the PR was opened. Muted "left blank"
|
||||||
|
treatment when none was supplied. */}
|
||||||
|
<div className="pr-use-case" style={{ margin: '6px 0', fontSize: 13 }}>
|
||||||
|
<span style={{ fontWeight: 700, color: '#888', textTransform: 'uppercase', letterSpacing: '0.05em', fontSize: 11 }}>
|
||||||
|
Intended use case:
|
||||||
|
</span>{' '}
|
||||||
|
{pr.proposed_use_case
|
||||||
|
? <span style={{ whiteSpace: 'pre-wrap' }}>{pr.proposed_use_case}</span>
|
||||||
|
: <span style={{ color: '#999', fontStyle: 'italic' }}>left blank</span>}
|
||||||
|
</div>
|
||||||
{pr.capabilities?.can_edit_text && (
|
{pr.capabilities?.can_edit_text && (
|
||||||
<button className="btn-link" onClick={startHeaderEdit}>Edit title & description</button>
|
<button className="btn-link" onClick={startHeaderEdit}>Edit title & description</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -163,6 +163,14 @@ export default function ProposalView({ viewer, onChange }) {
|
|||||||
className="entry-body"
|
className="entry-body"
|
||||||
dangerouslySetInnerHTML={{ __html: marked.parse(data.entry?.body || '') }}
|
dangerouslySetInnerHTML={{ __html: marked.parse(data.entry?.body || '') }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* #26: the optional ground-truth use case the proposer supplied. */}
|
||||||
|
<h3 style={{ fontSize: 13, fontWeight: 700, color: '#888', textTransform: 'uppercase', letterSpacing: '0.05em', marginTop: 24 }}>
|
||||||
|
Intended use case
|
||||||
|
</h3>
|
||||||
|
{data.proposed_use_case
|
||||||
|
? <div className="entry-body" dangerouslySetInnerHTML={{ __html: marked.parse(data.proposed_use_case) }} />
|
||||||
|
: <p style={{ color: '#999', fontStyle: 'italic' }}>Left blank by the proposer.</p>}
|
||||||
</article>
|
</article>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ export default function ProposeModal({ viewer, onClose, onSubmitted }) {
|
|||||||
const [slug, setSlug] = useState('')
|
const [slug, setSlug] = useState('')
|
||||||
const [slugEdited, setSlugEdited] = useState(false)
|
const [slugEdited, setSlugEdited] = useState(false)
|
||||||
const [pitch, setPitch] = useState('')
|
const [pitch, setPitch] = useState('')
|
||||||
|
// #26: optional ground-truth use case, sibling to the required pitch.
|
||||||
|
const [useCase, setUseCase] = useState('')
|
||||||
const [tagInput, setTagInput] = useState('')
|
const [tagInput, setTagInput] = useState('')
|
||||||
const [tags, setTags] = useState([])
|
const [tags, setTags] = useState([])
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
@@ -52,6 +54,7 @@ export default function ProposeModal({ viewer, onClose, onSubmitted }) {
|
|||||||
slug,
|
slug,
|
||||||
pitch: pitch.trim(),
|
pitch: pitch.trim(),
|
||||||
tags,
|
tags,
|
||||||
|
proposedUseCase: useCase.trim() || null,
|
||||||
})
|
})
|
||||||
// v0.15.0 — analytics: fire on the §9.1 propose-RFC submit.
|
// v0.15.0 — analytics: fire on the §9.1 propose-RFC submit.
|
||||||
// Slug is a stable, low-cardinality identifier (kebab-case
|
// Slug is a stable, low-cardinality identifier (kebab-case
|
||||||
@@ -105,6 +108,19 @@ export default function ProposeModal({ viewer, onClose, onSubmitted }) {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<label htmlFor="propose-use-case">What will you be using this RFC for? (optional)</label>
|
||||||
|
<textarea
|
||||||
|
id="propose-use-case"
|
||||||
|
value={useCase}
|
||||||
|
onChange={e => setUseCase(e.target.value)}
|
||||||
|
placeholder="The concrete thing you intend to build or do with this RFC. Optional, but it helps ground the work."
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
<p className="field-help">
|
||||||
|
The concrete ground-truth use case — distinct from "why it's
|
||||||
|
needed" above. Leave blank if you'd rather not say.
|
||||||
|
</p>
|
||||||
|
|
||||||
<label htmlFor="propose-tag">Tags (optional)</label>
|
<label htmlFor="propose-tag">Tags (optional)</label>
|
||||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginBottom: 4 }}>
|
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginBottom: 4 }}>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -669,6 +669,19 @@ export default function RFCView({ viewer }) {
|
|||||||
: 'main is read-only — PRs are the only path to change it. Open a branch to propose edits.'}
|
: 'main is read-only — PRs are the only path to change it. Open a branch to propose edits.'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* #26: the optional ground-truth use case captured at propose
|
||||||
|
time. Shown on the canonical (main) view; muted "left blank"
|
||||||
|
treatment when the proposer didn't supply one. */}
|
||||||
|
{branchParam === 'main' && (
|
||||||
|
<div className="rfc-use-case" style={{ margin: '8px 0 16px', padding: '10px 14px', borderLeft: '3px solid #e0e0e0', background: '#fafafa' }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 700, color: '#888', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4 }}>
|
||||||
|
Intended use case
|
||||||
|
</div>
|
||||||
|
{entry.proposed_use_case
|
||||||
|
? <div style={{ whiteSpace: 'pre-wrap' }}>{entry.proposed_use_case}</div>
|
||||||
|
: <span style={{ color: '#999', fontStyle: 'italic' }}>Left blank by the proposer.</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{inDiscuss && branchParam !== 'main' && (
|
{inDiscuss && branchParam !== 'main' && (
|
||||||
<div className="discuss-mode-banner">
|
<div className="discuss-mode-banner">
|
||||||
Discuss mode on <strong>{branchParam}</strong> — chat freely;
|
Discuss mode on <strong>{branchParam}</strong> — chat freely;
|
||||||
|
|||||||
Reference in New Issue
Block a user