Slice 5: graduation per §13
The §13.3 transactional sequence flips a super-draft to active — five steps with paired undoes, an in-process orchestrator fed by an asyncio.Queue, the §17 SSE endpoint streaming step transitions to the dialog. Each step is a new bot primitive that logs an `actions` row, bracketed by `graduate_start` / `graduate_complete` for the linkable audit sequence. Rollback runs the undoes in reverse from the last completed step; merge_pr has no undo by design per §13.5. The §9.8 precondition gate is enforced server-side at the top of POST /graduate so the §13.3 rollback complexity does not grow. The §13.4 chat migration is a database semantic no-op — the (slug, branch_name='main') threads keep their identity, only the interpretation changes. The §9.8 pre-graduation history surfaces via a new _is_meta_target(rfc, branch) dispatch helper and lands as pre_graduation_history on /main. §13.1 claim flow landed alongside since it's the prerequisite for non-admin graduation — bot.open_claim_pr plus broadening api_prs._require_pr to accept meta_claim. 45/45 tests green; ten new integration tests cover the validator, the §9.8 precondition refusal, happy path with audit verification, mid-sequence rollback at steps 2 and 3, concurrent refusal, chat-survives-without-data-movement, pre-graduation history, and the §13.1 claim PR cycle. SPEC.md §19.1 rewritten for Slice 6 (notifications); §19.2 grew four candidates surfaced during the slice. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,566 @@
|
||||
"""End-to-end integration tests for the Slice 5 vertical (§13 in full).
|
||||
|
||||
Walks the §13.3 transactional sequence end-to-end against the in-process
|
||||
FakeGitea from test_propose_vertical.py:
|
||||
|
||||
* Seed an owned super-draft (skipping the propose+merge + §13.1 claim
|
||||
round-trips already proven by Slice 1 and exercised in
|
||||
test_claim_opens_meta_pr below for the §13.1 surface itself).
|
||||
* GET /api/rfcs/<slug>/graduate/check returns per-field validity for
|
||||
the dialog.
|
||||
* GET /api/rfcs/<slug>/blocking-prs returns the §9.8 precondition list.
|
||||
* POST /api/rfcs/<slug>/graduate?_sync=1 runs the five-step sequence
|
||||
inline. On success: per-RFC repo exists with RFC.md / README.md /
|
||||
.rfc/metadata.yaml, meta-entry body is stripped, frontmatter is
|
||||
graduated, cached_rfcs.state is 'active'.
|
||||
* §9.8 precondition gate refuses the start when a body-edit PR is open.
|
||||
* Rollback on a mid-sequence failure unwinds repo creation cleanly.
|
||||
* §13.4 chat migration: whole-doc threads under (slug, 'main') survive
|
||||
graduation unchanged — the rfc_slug is the canonical key per §2.3,
|
||||
so no data movement is needed.
|
||||
* §9.8 pre-graduation history: the new RFC's /main response surfaces
|
||||
edit-branch threads under `pre_graduation_history`.
|
||||
|
||||
The orchestrator's `?_sync=1` seam awaits the sequence inline so the
|
||||
test can assert post-conditions on the same event loop tick. Production
|
||||
clients use the spec-described SSE shape via `/graduate/progress`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json as _json
|
||||
|
||||
import pytest
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
FakeGitea,
|
||||
app_with_fake_gitea,
|
||||
provision_user_row,
|
||||
sign_in_as,
|
||||
tmp_env,
|
||||
)
|
||||
from test_super_draft_vertical import seed_super_draft # noqa: F401
|
||||
|
||||
|
||||
PITCH = (
|
||||
"Open Human Model is a framework for representing humans.\n\n"
|
||||
"It defines consent, trait, and agency in compatible terms."
|
||||
)
|
||||
|
||||
|
||||
def seed_owned_super_draft(fake: FakeGitea, *, slug: str, title: str, pitch: str,
|
||||
owners: list[str], arbiters: list[str] | None = None,
|
||||
proposed_by: str = "alice", tags: list[str] | None = None) -> None:
|
||||
"""Seed a super-draft directly with owners already filled in — the
|
||||
§13.1 claim flow is exercised separately."""
|
||||
import yaml
|
||||
from app import db
|
||||
|
||||
fm = {
|
||||
"slug": slug,
|
||||
"title": title,
|
||||
"state": "super-draft",
|
||||
"id": None,
|
||||
"repo": None,
|
||||
"proposed_by": proposed_by,
|
||||
"proposed_at": "2026-05-23",
|
||||
"graduated_at": None,
|
||||
"graduated_by": None,
|
||||
"owners": owners,
|
||||
"arbiters": arbiters or owners[:1],
|
||||
"tags": tags or [],
|
||||
}
|
||||
body = pitch.strip() + "\n"
|
||||
entry_text = f"---\n{yaml.safe_dump(fm, sort_keys=False).rstrip()}\n---\n\n{body}"
|
||||
sha = fake._next_sha()
|
||||
fake.files[("wiggleverse", "meta", "main", f"rfcs/{slug}.md")] = {
|
||||
"content": entry_text, "sha": sha,
|
||||
}
|
||||
fake.branches[("wiggleverse", "meta")]["main"]["sha"] = sha
|
||||
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO cached_rfcs
|
||||
(slug, title, state, rfc_id, repo, proposed_by, proposed_at,
|
||||
owners_json, arbiters_json, tags_json,
|
||||
body, body_sha, last_main_commit_at, last_entry_commit_at)
|
||||
VALUES (?, ?, 'super-draft', NULL, NULL, ?, '2026-05-23',
|
||||
?, ?, ?, ?, ?, datetime('now'), datetime('now'))
|
||||
""",
|
||||
(
|
||||
slug, title, proposed_by,
|
||||
_json.dumps(owners),
|
||||
_json.dumps(arbiters or owners[:1]),
|
||||
_json.dumps(tags or []),
|
||||
body, sha,
|
||||
),
|
||||
)
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO cached_branches
|
||||
(rfc_slug, branch_name, head_sha, state, last_commit_at)
|
||||
VALUES (?, 'main', ?, 'open', datetime('now'))
|
||||
""",
|
||||
(slug, sha),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_graduate_check_validates_three_fields(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=1, login="ben", role="owner")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="Open Human Model",
|
||||
pitch=PITCH, owners=["ben"])
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
|
||||
# Happy: a fresh RFC-0001 + rfc-0001-ohm repo name.
|
||||
r = client.get("/api/rfcs/ohm/graduate/check",
|
||||
params={"id": "RFC-0001", "repo": "rfc-0001-ohm"})
|
||||
assert r.status_code == 200, r.text
|
||||
d = r.json()
|
||||
assert d["id"]["ok"] is True
|
||||
assert d["repo"]["ok"] is True
|
||||
assert d["owners"]["ok"] is True
|
||||
assert d["blocking_prs"]["ok"] is True
|
||||
assert d["can_submit"] is True
|
||||
|
||||
# ID format error — non-numeric tail.
|
||||
r = client.get("/api/rfcs/ohm/graduate/check",
|
||||
params={"id": "RFC-abcd", "repo": "rfc-0001-ohm"})
|
||||
d = r.json()
|
||||
assert d["id"]["ok"] is False
|
||||
assert d["can_submit"] is False
|
||||
|
||||
# Repo name pattern error — leading dot.
|
||||
r = client.get("/api/rfcs/ohm/graduate/check",
|
||||
params={"id": "RFC-0001", "repo": ".bad"})
|
||||
d = r.json()
|
||||
assert d["repo"]["ok"] is False
|
||||
|
||||
|
||||
def test_graduate_check_refuses_when_no_owners(app_with_fake_gitea):
|
||||
"""An unclaimed super-draft fails the owners precondition; can_submit
|
||||
flips false even with valid id+repo."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
# No owners — simulates an unclaimed super-draft.
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM", pitch=PITCH, owners=[])
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
r = client.get("/api/rfcs/ohm/graduate/check",
|
||||
params={"id": "RFC-0001", "repo": "rfc-0001-ohm"})
|
||||
d = r.json()
|
||||
assert d["owners"]["ok"] is False
|
||||
assert "No owners" in d["owners"]["error"]
|
||||
assert d["can_submit"] is False
|
||||
|
||||
|
||||
def test_graduate_happy_path_runs_five_steps_and_flips_state(app_with_fake_gitea):
|
||||
"""The full §13.3 sequence: create repo, seed files, open PR, merge
|
||||
PR, refresh cache. End state: cached_rfcs.state='active', the meta
|
||||
entry's body is stripped, the per-RFC repo has RFC.md, the audit
|
||||
log carries graduate_start → graduate_complete bracketing the
|
||||
per-step rows."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db, entry as entry_mod
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="Open Human Model",
|
||||
pitch=PITCH, owners=["ben"], arbiters=["ben"],
|
||||
tags=["identity", "schema"])
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner", email="ben@test")
|
||||
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/graduate?_sync=1",
|
||||
json={"rfc_id": "RFC-0042", "repo_name": "rfc-0042-ohm",
|
||||
"owners": ["ben"]},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
d = r.json()
|
||||
assert d["finished"] is True
|
||||
assert d["succeeded"] is True
|
||||
assert d["repo"] == "wiggleverse/rfc-0042-ohm"
|
||||
|
||||
# 1. Per-RFC repo exists on Gitea.
|
||||
assert ("wiggleverse", "rfc-0042-ohm") in fake.repos
|
||||
# 2. Seed files landed on main.
|
||||
assert ("wiggleverse", "rfc-0042-ohm", "main", "RFC.md") in fake.files
|
||||
assert ("wiggleverse", "rfc-0042-ohm", "main", "README.md") in fake.files
|
||||
assert ("wiggleverse", "rfc-0042-ohm", "main", ".rfc/metadata.yaml") in fake.files
|
||||
rfc_md = fake.files[("wiggleverse", "rfc-0042-ohm", "main", "RFC.md")]["content"]
|
||||
assert "Open Human Model is a framework" in rfc_md
|
||||
# 3. Meta entry body is stripped + frontmatter graduated.
|
||||
meta_text = fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
|
||||
graduated = entry_mod.parse(meta_text)
|
||||
assert graduated.state == "active"
|
||||
assert graduated.id == "RFC-0042"
|
||||
assert graduated.repo == "wiggleverse/rfc-0042-ohm"
|
||||
assert graduated.graduated_by == "ben"
|
||||
assert graduated.graduated_at # non-empty ISO date
|
||||
assert graduated.body.strip() == ""
|
||||
# 5. cached_rfcs.state flipped to active via the inline refresh.
|
||||
cached = db.conn().execute(
|
||||
"SELECT state, rfc_id, repo, body FROM cached_rfcs WHERE slug = 'ohm'"
|
||||
).fetchone()
|
||||
assert cached["state"] == "active"
|
||||
assert cached["rfc_id"] == "RFC-0042"
|
||||
assert cached["repo"] == "wiggleverse/rfc-0042-ohm"
|
||||
# cached body now mirrors RFC.md from the per-RFC repo.
|
||||
assert "Open Human Model is a framework" in cached["body"]
|
||||
|
||||
# Audit log: graduate_start, graduate_repo_create, graduate_repo_seed,
|
||||
# graduate_pr_open, graduate_pr_merge, graduate_complete, in order.
|
||||
kinds = [
|
||||
r["action_kind"]
|
||||
for r in db.conn().execute(
|
||||
"SELECT action_kind FROM actions WHERE rfc_slug = 'ohm' ORDER BY id"
|
||||
)
|
||||
]
|
||||
for needed in ("graduate_start", "graduate_repo_create",
|
||||
"graduate_repo_seed", "graduate_pr_open",
|
||||
"graduate_pr_merge", "graduate_complete"):
|
||||
assert needed in kinds, f"missing audit row {needed}: {kinds}"
|
||||
|
||||
|
||||
def test_graduate_refuses_when_body_edit_pr_open(app_with_fake_gitea):
|
||||
"""§9.8: an open meta-repo body-edit PR against rfcs/<slug>.md blocks
|
||||
graduation before the bot starts the sequence — §13.3's rollback
|
||||
complexity does not grow."""
|
||||
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="ben", role="owner")
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||
pitch=PITCH, owners=["ben"])
|
||||
sign_in_as(client, user_id=2, gitea_login="alice",
|
||||
display_name="Alice", role="contributor")
|
||||
|
||||
# Cut an edit branch and open a body-edit PR (full Slice 4 path).
|
||||
branch = client.post("/api/rfcs/ohm/start-edit-branch", json={}).json()["branch_name"]
|
||||
view = client.get(f"/api/rfcs/ohm/branches/{branch}").json()
|
||||
thread_id = view["main_thread_id"]
|
||||
cur = db.conn().execute(
|
||||
"""
|
||||
INSERT INTO changes
|
||||
(rfc_slug, branch_name, thread_id, kind, state, original, proposed, reason)
|
||||
VALUES ('ohm', ?, ?, 'ai', 'pending', ?, ?, 'tighten')
|
||||
""",
|
||||
(branch, thread_id,
|
||||
"It defines consent, trait, and agency in compatible terms.",
|
||||
"It defines consent, trait, harm, and agency in compatible terms."),
|
||||
)
|
||||
change_id = cur.lastrowid
|
||||
client.post(
|
||||
f"/api/rfcs/ohm/branches/{branch}/changes/{change_id}/accept",
|
||||
json={"proposed": "It defines consent, trait, harm, and agency in compatible terms.",
|
||||
"was_edited_before_accept": False},
|
||||
)
|
||||
pr_number = client.post(
|
||||
f"/api/rfcs/ohm/branches/{branch}/open-pr",
|
||||
json={"title": "Add harm", "description": "Adds harm dimension."},
|
||||
).json()["pr_number"]
|
||||
|
||||
# /blocking-prs surfaces it.
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
r = client.get("/api/rfcs/ohm/blocking-prs")
|
||||
items = r.json()["items"]
|
||||
assert len(items) == 1
|
||||
assert items[0]["pr_number"] == pr_number
|
||||
|
||||
# /check refuses can_submit.
|
||||
r = client.get("/api/rfcs/ohm/graduate/check",
|
||||
params={"id": "RFC-0001", "repo": "rfc-0001-ohm"})
|
||||
d = r.json()
|
||||
assert d["blocking_prs"]["ok"] is False
|
||||
assert d["can_submit"] is False
|
||||
|
||||
# POST refuses with 409 — the bot never starts the sequence.
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/graduate?_sync=1",
|
||||
json={"rfc_id": "RFC-0001", "repo_name": "rfc-0001-ohm",
|
||||
"owners": ["ben"]},
|
||||
)
|
||||
assert r.status_code == 409
|
||||
assert "blocking graduation" in r.text or "block" in r.text
|
||||
|
||||
|
||||
def test_graduate_rollback_on_step_2_seed_failure(app_with_fake_gitea):
|
||||
"""Step 2 (seed files) fails partway → the orchestrator rolls back
|
||||
step 1 (delete the repo) and records the rollback in the audit log.
|
||||
The cached_rfcs row stays at 'super-draft'."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
from app.bot import Bot
|
||||
from app.gitea import Gitea, GiteaError
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||
pitch=PITCH, owners=["ben"])
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
|
||||
# Monkey-patch the bot to fail on seed_graduated_rfc. The repo
|
||||
# has already been created in step 1; the rollback must delete it.
|
||||
orig_seed = Bot.seed_graduated_rfc
|
||||
async def boom(self, *args, **kwargs):
|
||||
raise GiteaError(500, "simulated seed failure for rollback test")
|
||||
Bot.seed_graduated_rfc = boom
|
||||
try:
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/graduate?_sync=1",
|
||||
json={"rfc_id": "RFC-0003", "repo_name": "rfc-0003-ohm",
|
||||
"owners": ["ben"]},
|
||||
)
|
||||
finally:
|
||||
Bot.seed_graduated_rfc = orig_seed
|
||||
assert r.status_code == 200, r.text
|
||||
d = r.json()
|
||||
assert d["finished"] is True
|
||||
assert d["succeeded"] is False
|
||||
|
||||
# Repo deleted as the rollback inverse.
|
||||
assert ("wiggleverse", "rfc-0003-ohm") not in fake.repos
|
||||
# Meta entry unchanged.
|
||||
cached = db.conn().execute(
|
||||
"SELECT state, rfc_id FROM cached_rfcs WHERE slug = 'ohm'"
|
||||
).fetchone()
|
||||
assert cached["state"] == "super-draft"
|
||||
assert cached["rfc_id"] is None
|
||||
# Audit log carries the rollback row.
|
||||
kinds = [
|
||||
r["action_kind"]
|
||||
for r in db.conn().execute(
|
||||
"SELECT action_kind FROM actions WHERE rfc_slug = 'ohm' ORDER BY id"
|
||||
)
|
||||
]
|
||||
assert "graduate_start" in kinds
|
||||
assert "graduate_repo_create" in kinds
|
||||
assert "graduate_repo_delete" in kinds
|
||||
assert "graduate_rollback" in kinds
|
||||
assert "graduate_complete" not in kinds
|
||||
|
||||
|
||||
def test_graduate_rollback_on_step_3_pr_open_failure(app_with_fake_gitea):
|
||||
"""Step 3 (open PR) fails → the orchestrator rolls back steps 2 and
|
||||
1 (deleting the repo, which reclaims the seed commits at the same
|
||||
time). The meta-repo entry is untouched."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
from app.bot import Bot
|
||||
from app.gitea import GiteaError
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||
pitch=PITCH, owners=["ben"])
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
|
||||
orig_open_pr = Bot.open_graduation_pr
|
||||
async def boom(self, *args, **kwargs):
|
||||
raise GiteaError(502, "simulated PR-open failure")
|
||||
Bot.open_graduation_pr = boom
|
||||
try:
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/graduate?_sync=1",
|
||||
json={"rfc_id": "RFC-0007", "repo_name": "rfc-0007-ohm",
|
||||
"owners": ["ben"]},
|
||||
)
|
||||
finally:
|
||||
Bot.open_graduation_pr = orig_open_pr
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["succeeded"] is False
|
||||
# Repo torn down.
|
||||
assert ("wiggleverse", "rfc-0007-ohm") not in fake.repos
|
||||
# Meta entry's body still has the pitch (not stripped).
|
||||
meta_text = fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")]["content"]
|
||||
assert "Open Human Model is a framework" in meta_text
|
||||
|
||||
|
||||
def test_graduate_refuses_concurrent_graduation(app_with_fake_gitea):
|
||||
"""A second graduation request for a slug already in-flight is refused."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import api_graduation
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=1, login="ben", role="owner")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||
pitch=PITCH, owners=["ben"])
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
|
||||
# Seed a synthetic in-flight state so the registry refuses the second.
|
||||
st = api_graduation._new_active(
|
||||
"ohm", rfc_id="RFC-0001", repo_name="rfc-0001-ohm",
|
||||
repo_full="wiggleverse/rfc-0001-ohm", owners=["ben"], arbiters=["ben"],
|
||||
)
|
||||
st.finished = False
|
||||
try:
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/graduate?_sync=1",
|
||||
json={"rfc_id": "RFC-0001", "repo_name": "rfc-0001-ohm",
|
||||
"owners": ["ben"]},
|
||||
)
|
||||
assert r.status_code == 409
|
||||
finally:
|
||||
api_graduation._active.pop("ohm", None)
|
||||
|
||||
|
||||
def test_chat_threads_survive_graduation_without_data_movement(app_with_fake_gitea):
|
||||
"""§13.4: chat threads on the super-draft's canonical-body view
|
||||
(`branch_name='main'`) are interpreted as the new RFC's main-thread
|
||||
after graduation. The rows don't move — the rfc_slug is canonical
|
||||
per §2.3 — so the same thread surfaces from both before and after
|
||||
the graduation."""
|
||||
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="ben", role="owner")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||
pitch=PITCH, owners=["ben"])
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
|
||||
# Materialize a whole-doc main thread + a message on it. This
|
||||
# mirrors what reading the canonical-body view would create
|
||||
# lazily (§8.12 / api_branches._ensure_branch_chat_thread).
|
||||
cur = db.conn().execute(
|
||||
"""
|
||||
INSERT INTO threads (rfc_slug, branch_name, anchor_kind, thread_kind, created_by)
|
||||
VALUES ('ohm', 'main', 'whole-doc', 'chat', 1)
|
||||
"""
|
||||
)
|
||||
thread_id = cur.lastrowid
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO thread_messages (thread_id, role, author_user_id, text)
|
||||
VALUES (?, 'user', 1, 'pre-grad note on the canonical body')
|
||||
""",
|
||||
(thread_id,),
|
||||
)
|
||||
|
||||
# Graduate.
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/graduate?_sync=1",
|
||||
json={"rfc_id": "RFC-0099", "repo_name": "rfc-0099-ohm",
|
||||
"owners": ["ben"]},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# The thread row's identity is unchanged.
|
||||
row = db.conn().execute(
|
||||
"SELECT id, branch_name FROM threads WHERE id = ?", (thread_id,),
|
||||
).fetchone()
|
||||
assert row["branch_name"] == "main"
|
||||
# The new RFC's main view surfaces the same thread id as its
|
||||
# whole-doc main thread (the entry is now active, the branch
|
||||
# 'main' now points at the per-RFC repo's main, but the
|
||||
# `(rfc_slug, branch_name)` key remains the canonical anchor).
|
||||
r = client.get("/api/rfcs/ohm/branches/main")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["main_thread_id"] == thread_id
|
||||
|
||||
|
||||
def test_pre_graduation_history_surfaces_edit_branch_threads(app_with_fake_gitea):
|
||||
"""§9.8: after graduation, threads on meta-repo edit branches stay
|
||||
attached to their original branch_name and surface from the new
|
||||
RFC's /main response under `pre_graduation_history`."""
|
||||
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="ben", role="owner")
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||
pitch=PITCH, owners=["ben"])
|
||||
|
||||
# Alice cuts an edit branch and starts chatting on it.
|
||||
sign_in_as(client, user_id=2, gitea_login="alice",
|
||||
display_name="Alice", role="contributor")
|
||||
branch = client.post("/api/rfcs/ohm/start-edit-branch", json={}).json()["branch_name"]
|
||||
view = client.get(f"/api/rfcs/ohm/branches/{branch}").json()
|
||||
thread_id = view["main_thread_id"]
|
||||
db.conn().execute(
|
||||
"""
|
||||
INSERT INTO thread_messages (thread_id, role, author_user_id, text)
|
||||
VALUES (?, 'user', 2, 'pre-graduation note on an edit branch')
|
||||
""",
|
||||
(thread_id,),
|
||||
)
|
||||
|
||||
# Ben graduates.
|
||||
sign_in_as(client, user_id=1, gitea_login="ben",
|
||||
display_name="Ben", role="owner")
|
||||
r = client.post(
|
||||
"/api/rfcs/ohm/graduate?_sync=1",
|
||||
json={"rfc_id": "RFC-0100", "repo_name": "rfc-0100-ohm",
|
||||
"owners": ["ben"]},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# /main on the now-active RFC surfaces the pre-graduation history.
|
||||
r = client.get("/api/rfcs/ohm/main")
|
||||
d = r.json()
|
||||
assert d["state"] == "active"
|
||||
hist = d["pre_graduation_history"]
|
||||
assert len(hist) >= 1
|
||||
assert any(h["branch_name"] == branch for h in hist)
|
||||
target = next(h for h in hist if h["branch_name"] == branch)
|
||||
assert target["message_count"] >= 1
|
||||
|
||||
|
||||
def test_claim_opens_meta_pr(app_with_fake_gitea):
|
||||
"""§13.1: any signed-in contributor can claim ownership of an
|
||||
unclaimed super-draft; the result is a meta-repo PR
|
||||
(`pr_kind='meta_claim'`) adding their gitea_login to the entry's
|
||||
owners list."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db, entry as entry_mod
|
||||
|
||||
app, fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
provision_user_row(user_id=2, login="alice", role="contributor")
|
||||
seed_owned_super_draft(fake, slug="ohm", title="OHM",
|
||||
pitch=PITCH, owners=[])
|
||||
sign_in_as(client, user_id=2, gitea_login="alice",
|
||||
display_name="Alice", role="contributor")
|
||||
|
||||
r = client.post("/api/rfcs/ohm/claim")
|
||||
assert r.status_code == 200, r.text
|
||||
d = r.json()
|
||||
assert d["branch_name"] == "claim/ohm"
|
||||
|
||||
# The PR body's diff carries Alice in owners.
|
||||
text = fake.files[("wiggleverse", "meta", "claim/ohm", "rfcs/ohm.md")]["content"]
|
||||
ent = entry_mod.parse(text)
|
||||
assert "alice" in ent.owners
|
||||
|
||||
# cached_prs records pr_kind='meta_claim' via refresh_meta_pulls.
|
||||
row = db.conn().execute(
|
||||
"SELECT pr_kind FROM cached_prs WHERE pr_number = ?", (d["pr_number"],),
|
||||
).fetchone()
|
||||
assert row["pr_kind"] == "meta_claim"
|
||||
Reference in New Issue
Block a user