1b0968a9a2
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>
567 lines
24 KiB
Python
567 lines
24 KiB
Python
"""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"
|