diff --git a/backend/src/api/patchnotes_routes.py b/backend/src/api/patchnotes_routes.py index de7ae32a..11ff9460 100644 --- a/backend/src/api/patchnotes_routes.py +++ b/backend/src/api/patchnotes_routes.py @@ -23,12 +23,18 @@ BulletNotPending, PatchnotesConfigError, PatchnotesDBError, + WeekNotPostponed, + WeekPostponed, approve_bullet, + approve_pending_week, add_folder_rule, current_week, + defer_postponed_week, deny_bullet, ensure_folder, + get_bullet, get_folder, + get_week_status, insert_bullet, insert_sourced_bullet, list_approved, @@ -41,10 +47,13 @@ migrate, observe_folder, parse_week, + postpone_week, queue_sync_task, remove_added_folder, replace_preview, request_added_folder, + revise_denied_bullet, + undo_postpone, ) from src.patchnotes.folders import catalog_entries, clean_folder_name, safe_rule from src.patchnotes.safety import hidden_knowledge_warning @@ -118,6 +127,9 @@ def _serialize(row: dict[str, Any], *, public: bool) -> dict[str, Any]: warning = hidden_knowledge_warning(str(row.get("body") or "")) if warning: payload["warning"] = warning + note = str(row.get("revision_note") or "").strip() + if note: + payload["revision_note"] = note return payload @@ -649,4 +661,98 @@ def staff_deny_bullet(bullet_id: str, body: DenyBulletBody): except (PatchnotesDBError, PatchnotesConfigError, psycopg2.Error) as e: logger.exception("staff_deny_bullet failed") raise HTTPException(status_code=502, detail=_client_detail(e)) from e + try: + revision = revise_denied_bullet(row, body.reason) + except (PatchnotesDBError, PatchnotesConfigError, psycopg2.Error, ValueError): + logger.exception("staff_deny_bullet rewrite failed") + revision = None + payload = _serialize(row, public=False) + payload["revision"] = _serialize(revision, public=False) if revision else None + return payload + + +@patchnotes_router.get("/staff/bullets/{bullet_id}", dependencies=[Depends(_staff_guard)]) +def staff_get_bullet(bullet_id: str): + bullet_id = _bullet_id_or_404(bullet_id) + try: + migrate() + row = get_bullet(bullet_id) + except BulletNotFound as e: + raise _review_http(e) from e + except (PatchnotesDBError, PatchnotesConfigError, psycopg2.Error) as e: + logger.exception("staff_get_bullet failed") + raise HTTPException(status_code=502, detail=_client_detail(e)) from e return _serialize(row, public=False) + + +def _week_payload(row: dict[str, Any]) -> dict[str, Any]: + return { + "week": row["week"], + "postponed": bool(row.get("postponed")), + "deferred_to": row.get("deferred_to"), + } + + +@patchnotes_router.get("/staff/weeks/{week}", dependencies=[Depends(_staff_guard)]) +def staff_week_status(week: str): + week_key = _week_or_400(week) + try: + migrate() + row = get_week_status(week_key) + except (PatchnotesDBError, PatchnotesConfigError, psycopg2.Error) as e: + logger.exception("staff_week_status failed") + raise HTTPException(status_code=502, detail=_client_detail(e)) from e + return _week_payload(row) + + +@patchnotes_router.post("/staff/weeks/{week}/postpone", dependencies=[Depends(_staff_guard)]) +def staff_postpone_week(week: str): + week_key = _week_or_400(week) + try: + migrate() + row = postpone_week(week_key) + except (PatchnotesDBError, PatchnotesConfigError, psycopg2.Error) as e: + logger.exception("staff_postpone_week failed") + raise HTTPException(status_code=502, detail=_client_detail(e)) from e + return _week_payload(row) + + +@patchnotes_router.post("/staff/weeks/{week}/undo-postpone", dependencies=[Depends(_staff_guard)]) +def staff_undo_postpone(week: str): + week_key = _week_or_400(week) + try: + migrate() + row = undo_postpone(week_key) + except (PatchnotesDBError, PatchnotesConfigError, psycopg2.Error) as e: + logger.exception("staff_undo_postpone failed") + raise HTTPException(status_code=502, detail=_client_detail(e)) from e + return _week_payload(row) + + +@patchnotes_router.post("/staff/weeks/{week}/defer", dependencies=[Depends(_staff_guard)]) +def staff_defer_week(week: str): + week_key = _week_or_400(week) + try: + migrate() + row = defer_postponed_week(week_key) + except WeekNotPostponed as e: + raise HTTPException(status_code=409, detail="Week is not postponed") from e + except (PatchnotesDBError, PatchnotesConfigError, psycopg2.Error) as e: + logger.exception("staff_defer_week failed") + raise HTTPException(status_code=502, detail=_client_detail(e)) from e + return _week_payload(row) + + +@patchnotes_router.post("/staff/weeks/{week}/auto-approve", dependencies=[Depends(_staff_guard)]) +def staff_auto_approve_week(week: str): + """Approve every line still waiting, except rewrites.""" + week_key = _week_or_400(week) + try: + migrate() + approved = approve_pending_week(week_key) + except WeekPostponed as e: + raise HTTPException(status_code=409, detail="Week is postponed") from e + except (PatchnotesDBError, PatchnotesConfigError, psycopg2.Error) as e: + logger.exception("staff_auto_approve_week failed") + raise HTTPException(status_code=502, detail=_client_detail(e)) from e + return {"week": week_key, "approved": approved} diff --git a/backend/src/api/test_patchnotes_routes.py b/backend/src/api/test_patchnotes_routes.py index 69634880..7480a6a2 100644 --- a/backend/src/api/test_patchnotes_routes.py +++ b/backend/src/api/test_patchnotes_routes.py @@ -25,7 +25,13 @@ from fastapi.testclient import TestClient # noqa: E402 from server import app # noqa: E402 -from src.patchnotes.db import BulletNotFound, BulletNotPending, PatchnotesDBError # noqa: E402 +from src.patchnotes.db import ( # noqa: E402 + BulletNotFound, + BulletNotPending, + PatchnotesDBError, + WeekNotPostponed, + WeekPostponed, +) _HEADERS = {"X-Staff-Key": "dev-staff-key"} _CREATED = datetime(2026, 9, 23, 12, 0, tzinfo=timezone.utc) @@ -76,6 +82,16 @@ def test_staff_routes_require_auth(self) -> None: ).status_code, 401, ) + self.assertEqual(self.client.get(f"/patchnotes/staff/bullets/{_BULLET_ID}").status_code, 401) + self.assertEqual(self.client.get("/patchnotes/staff/weeks/2026-W39").status_code, 401) + self.assertEqual(self.client.post("/patchnotes/staff/weeks/2026-W39/postpone").status_code, 401) + self.assertEqual( + self.client.post("/patchnotes/staff/weeks/2026-W39/undo-postpone").status_code, 401 + ) + self.assertEqual(self.client.post("/patchnotes/staff/weeks/2026-W39/defer").status_code, 401) + self.assertEqual( + self.client.post("/patchnotes/staff/weeks/2026-W39/auto-approve").status_code, 401 + ) def test_bad_staff_key_does_not_fall_through_to_session(self) -> None: res = self.client.get( @@ -177,20 +193,49 @@ def test_approve(self, mock_approve) -> None: self.assertEqual(res.json()["status"], "approved") mock_approve.assert_called_once_with(_BULLET_ID) + @mock.patch("src.api.patchnotes_routes.revise_denied_bullet", return_value=None) @mock.patch( "src.api.patchnotes_routes.deny_bullet", return_value=_row(status="denied", deny_reason="Internal only", reviewed_at=_CREATED), ) - def test_deny_stores_the_reason(self, mock_deny) -> None: + def test_deny_stores_the_reason(self, mock_deny, _mock_revise) -> None: res = self.client.post( f"/patchnotes/staff/bullets/{_BULLET_ID}/deny", json={"reason": "Internal only"}, headers=_HEADERS, ) self.assertEqual(res.status_code, 200) - self.assertEqual(res.json()["deny_reason"], "Internal only") + body = res.json() + self.assertEqual(body["deny_reason"], "Internal only") + self.assertIsNone(body["revision"]) mock_deny.assert_called_once_with(_BULLET_ID, "Internal only") + @mock.patch( + "src.api.patchnotes_routes.revise_denied_bullet", + return_value=_row( + id="22222222-2222-2222-2222-222222222222", + body="Added a station", + revision_note="Don't mention the vault", + ), + ) + @mock.patch( + "src.api.patchnotes_routes.deny_bullet", + return_value=_row(status="denied", deny_reason="Don't mention the vault", reviewed_at=_CREATED), + ) + def test_deny_returns_the_rewrite_without_putting_the_reason_in_the_body( + self, _mock_deny, _mock_revise + ) -> None: + res = self.client.post( + f"/patchnotes/staff/bullets/{_BULLET_ID}/deny", + json={"reason": "Don't mention the vault"}, + headers=_HEADERS, + ) + self.assertEqual(res.status_code, 200) + revision = res.json()["revision"] + self.assertEqual(revision["body"], "Added a station") + self.assertEqual(revision["revision_note"], "Don't mention the vault") + self.assertNotIn("vault", revision["body"]) + def test_deny_requires_a_reason(self) -> None: res = self.client.post( f"/patchnotes/staff/bullets/{_BULLET_ID}/deny", @@ -286,6 +331,7 @@ def test_published_weeks_need_no_auth(self, _mock_weeks) -> None: status="approved", deny_reason="should not leak", reviewed_at=_CREATED, + revision_note="staff only", ) ], ) @@ -298,6 +344,7 @@ def test_published_week_hides_review_fields(self, mock_list) -> None: self.assertNotIn("status", bullet) self.assertNotIn("reviewed_at", bullet) self.assertNotIn("warning", bullet) + self.assertNotIn("revision_note", bullet) mock_list.assert_called_once_with("2026-W39") def test_published_week_rejects_a_bad_week(self) -> None: @@ -492,3 +539,44 @@ def test_a_watch_note_is_once_per_plugin_per_week(self, mock_insert, _week) -> N mock_insert.call_args.kwargs["source_key"], "watch:MythicDungeons:2026-W39", ) + + +class WeekActionRouteTest(unittest.TestCase): + def setUp(self) -> None: + self.client = TestClient(app) + patcher = mock.patch("src.api.patchnotes_routes.migrate") + self.addCleanup(patcher.stop) + patcher.start() + + def tearDown(self) -> None: + self.client.close() + + @mock.patch( + "src.api.patchnotes_routes.postpone_week", + return_value={"week": "2026-W39", "postponed": True, "deferred_to": None}, + ) + def test_postpone(self, mock_postpone) -> None: + res = self.client.post("/patchnotes/staff/weeks/2026-W39/postpone", headers=_HEADERS) + self.assertEqual(res.status_code, 200) + self.assertTrue(res.json()["postponed"]) + mock_postpone.assert_called_once_with("2026-W39") + + @mock.patch( + "src.api.patchnotes_routes.defer_postponed_week", + side_effect=WeekNotPostponed("2026-W39"), + ) + def test_defer_requires_a_hold(self, _mock_defer) -> None: + res = self.client.post("/patchnotes/staff/weeks/2026-W39/defer", headers=_HEADERS) + self.assertEqual(res.status_code, 409) + + @mock.patch("src.api.patchnotes_routes.approve_pending_week", side_effect=WeekPostponed("2026-W39")) + def test_auto_approve_refuses_a_postponed_week(self, _mock_approve) -> None: + res = self.client.post("/patchnotes/staff/weeks/2026-W39/auto-approve", headers=_HEADERS) + self.assertEqual(res.status_code, 409) + + @mock.patch("src.api.patchnotes_routes.approve_pending_week", return_value=3) + def test_auto_approve_counts_lines(self, mock_approve) -> None: + res = self.client.post("/patchnotes/staff/weeks/2026-W39/auto-approve", headers=_HEADERS) + self.assertEqual(res.status_code, 200) + self.assertEqual(res.json()["approved"], 3) + mock_approve.assert_called_once_with("2026-W39") diff --git a/backend/src/patchnotes/db.py b/backend/src/patchnotes/db.py index b24fd881..8c13ba73 100644 --- a/backend/src/patchnotes/db.py +++ b/backend/src/patchnotes/db.py @@ -1,9 +1,9 @@ """Postgres storage for weekly patch note bullets. A bullet is one player-facing line in a week's note. Staff create it as -pending, then approve it or deny it with a reason. Approved bullets are the -only rows a public reader is allowed to see. Pending and denied rows stay on -the staff queue. +pending, then approve it or deny it with a reason. A denial that can be +rewritten becomes a new pending line. Approved bullets are the only rows a +public reader is allowed to see. A postponed week stays off the public page. """ from __future__ import annotations @@ -18,6 +18,8 @@ import psycopg2 import psycopg2.extras +from .revise import rewrite + logger = logging.getLogger("patchnotes.db") SECTIONS = ("new", "fixed", "adjusted", "technical") @@ -28,8 +30,16 @@ _WEEK_RE = re.compile(r"^(\d{4})-W(\d{2})$") _BULLET_COLUMNS = ( - "id, week, section, body, status, deny_reason, created_at, reviewed_at" + "id, week, section, body, status, deny_reason, created_at, reviewed_at, revision_note" ) +_NOT_POSTPONED = """ + AND NOT EXISTS ( + SELECT 1 FROM patchnote_week_status + WHERE patchnote_week_status.week = patchnote_bullets.week + AND patchnote_week_status.postponed + ) +""" +_MAX_REVISIONS = 3 _INSERT_BULLET = f""" INSERT INTO patchnote_bullets (week, section, body, status) VALUES (%s, %s, %s, 'pending') @@ -55,6 +65,7 @@ SELECT {_BULLET_COLUMNS} FROM patchnote_bullets WHERE week = %s AND status = 'approved' +{_NOT_POSTPONED} ORDER BY {_SECTION_ORDER}, created_at ASC, id ASC @@ -63,6 +74,7 @@ SELECT {_BULLET_COLUMNS} FROM patchnote_bullets WHERE status = 'approved' AND week = ANY(%s) +{_NOT_POSTPONED} ORDER BY week DESC, {_SECTION_ORDER}, created_at ASC, @@ -90,6 +102,14 @@ class BulletNotFound(LookupError): """No bullet exists with this id.""" +class WeekNotPostponed(ValueError): + """Defer was asked for a week that is not postponed.""" + + +class WeekPostponed(ValueError): + """Approve was asked for a week that is held.""" + + class BulletNotPending(RuntimeError): """The bullet exists but is no longer waiting for review.""" @@ -143,6 +163,18 @@ def _local_now(now: datetime | None = None) -> datetime: return moment.astimezone(_zone()) +def next_week(week: str) -> str: + """The ISO week after `week`.""" + week_key = parse_week(week) + match = _WEEK_RE.match(week_key) + if match is None: + raise ValueError("week must look like 2026-W39") + year, number = int(match.group(1)), int(match.group(2)) + monday = datetime.fromisocalendar(year, number, 1) + iso = (monday + timedelta(days=7)).isocalendar() + return f"{iso.year}-W{iso.week:02d}" + + def current_week(now: datetime | None = None) -> str: """ISO week a new bullet belongs to. @@ -271,6 +303,25 @@ def migrate() -> None: ) """ ) + cur.execute( + """ + ALTER TABLE patchnote_bullets + ADD COLUMN IF NOT EXISTS supersedes UUID, + ADD COLUMN IF NOT EXISTS revision_note TEXT, + ADD COLUMN IF NOT EXISTS carried_from TEXT, + ADD COLUMN IF NOT EXISTS carried_status TEXT + """ + ) + cur.execute( + """ + CREATE TABLE IF NOT EXISTS patchnote_week_status ( + week TEXT PRIMARY KEY, + postponed BOOLEAN NOT NULL DEFAULT FALSE, + deferred_to TEXT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + """ + ) cur.execute( """ CREATE TABLE IF NOT EXISTS patchnote_sync_tasks ( @@ -382,6 +433,11 @@ def list_published_notes( FROM patchnote_bullets WHERE status = 'approved' AND (%s::text IS NULL OR week < %s) + AND NOT EXISTS ( + SELECT 1 FROM patchnote_week_status + WHERE patchnote_week_status.week = patchnote_bullets.week + AND patchnote_week_status.postponed + ) GROUP BY week ORDER BY week DESC LIMIT %s @@ -416,6 +472,11 @@ def list_published_weeks() -> list[str]: SELECT week FROM patchnote_bullets WHERE status = 'approved' + AND NOT EXISTS ( + SELECT 1 FROM patchnote_week_status + WHERE patchnote_week_status.week = patchnote_bullets.week + AND patchnote_week_status.postponed + ) GROUP BY week ORDER BY week DESC """ @@ -702,3 +763,219 @@ def list_sync_tasks(week: str) -> list[str]: return [row[0] for row in cur.fetchall()] finally: conn.close() + + +def _week_status_row(week: str, row: dict[str, Any] | None) -> dict[str, Any]: + if row is None: + return {"week": week, "postponed": False, "deferred_to": None} + deferred = row.get("deferred_to") + return { + "week": week, + "postponed": bool(row.get("postponed")), + "deferred_to": str(deferred) if deferred else None, + } + + +def get_bullet(bullet_id: str) -> dict[str, Any]: + """One bullet, including a denied or approved row.""" + conn = _connect() + try: + with conn, conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + cur.execute( + f"SELECT {_BULLET_COLUMNS} FROM patchnote_bullets WHERE id = %s", + (bullet_id,), + ) + row = cur.fetchone() + if row is None: + raise BulletNotFound(bullet_id) + return dict(row) + finally: + conn.close() + + +def _revision_depth(cur, bullet_id: str) -> int: + depth = 0 + current = bullet_id + seen: set[str] = set() + while current and current not in seen and depth < _MAX_REVISIONS: + seen.add(current) + cur.execute( + "SELECT supersedes FROM patchnote_bullets WHERE id = %s", + (current,), + ) + row = cur.fetchone() + parent = None if row is None else row.get("supersedes") + if not parent: + break + current = str(parent) + depth += 1 + return depth + + +def revise_denied_bullet(denied: dict[str, Any], reason: str) -> dict[str, Any] | None: + """Store a rewritten pending line for one denial, or None when it stays denied.""" + proposal = rewrite(str(denied.get("section") or ""), str(denied.get("body") or ""), reason) + if proposal is None: + return None + section, text = proposal + note = _clean_reason(reason) + week_key = parse_week(str(denied.get("week") or "")) + bullet_id = str(denied["id"]) + conn = _connect() + try: + with conn, conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + if _revision_depth(cur, bullet_id) >= _MAX_REVISIONS: + return None + cur.execute( + f""" + INSERT INTO patchnote_bullets + (week, section, body, status, supersedes, revision_note) + VALUES (%s, %s, %s, 'pending', %s, %s) + RETURNING {_BULLET_COLUMNS} + """, + (week_key, section, text, bullet_id, note), + ) + return dict(cur.fetchone()) + finally: + conn.close() + + +def get_week_status(week: str) -> dict[str, Any]: + week_key = parse_week(week) + conn = _connect() + try: + with conn, conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + cur.execute( + """ + SELECT postponed, deferred_to + FROM patchnote_week_status + WHERE week = %s + """, + (week_key,), + ) + return _week_status_row(week_key, cur.fetchone()) + finally: + conn.close() + + +def postpone_week(week: str) -> dict[str, Any]: + """Hold a week. The public page hides it until the hold is undone or deferred.""" + week_key = parse_week(week) + conn = _connect() + try: + with conn, conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + cur.execute( + """ + INSERT INTO patchnote_week_status (week, postponed) + VALUES (%s, TRUE) + ON CONFLICT (week) DO UPDATE + SET postponed = TRUE, updated_at = now() + RETURNING postponed, deferred_to + """, + (week_key,), + ) + return _week_status_row(week_key, cur.fetchone()) + finally: + conn.close() + + +def undo_postpone(week: str) -> dict[str, Any]: + """Clear a hold and bring deferred lines back onto this week.""" + week_key = parse_week(week) + conn = _connect() + try: + with conn, conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + cur.execute( + "SELECT deferred_to FROM patchnote_week_status WHERE week = %s", + (week_key,), + ) + row = cur.fetchone() + if row is not None and row.get("deferred_to"): + cur.execute( + """ + UPDATE patchnote_bullets + SET week = %s, + status = COALESCE(carried_status, 'pending'), + reviewed_at = CASE + WHEN carried_status = 'approved' THEN now() + ELSE NULL + END, + carried_from = NULL, + carried_status = NULL + WHERE carried_from = %s + """, + (week_key, week_key), + ) + cur.execute( + """ + INSERT INTO patchnote_week_status (week, postponed, deferred_to) + VALUES (%s, FALSE, NULL) + ON CONFLICT (week) DO UPDATE + SET postponed = FALSE, deferred_to = NULL, updated_at = now() + RETURNING postponed, deferred_to + """, + (week_key,), + ) + return _week_status_row(week_key, cur.fetchone()) + finally: + conn.close() + + +def defer_postponed_week(week: str) -> dict[str, Any]: + """Move this week's notes onto the next week and keep them unpublished.""" + week_key = parse_week(week) + status = get_week_status(week_key) + if not status["postponed"]: + raise WeekNotPostponed(week_key) + if status["deferred_to"]: + return status + destination = next_week(week_key) + conn = _connect() + try: + with conn, conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + cur.execute( + """ + UPDATE patchnote_bullets + SET week = %s, + carried_from = week, + carried_status = status, + status = 'pending', + deny_reason = NULL, + reviewed_at = NULL + WHERE week = %s AND status IN ('pending', 'approved') + """, + (destination, week_key), + ) + cur.execute( + """ + UPDATE patchnote_week_status + SET deferred_to = %s, updated_at = now() + WHERE week = %s + RETURNING postponed, deferred_to + """, + (destination, week_key), + ) + return _week_status_row(week_key, cur.fetchone()) + finally: + conn.close() + + +def approve_pending_week(week: str) -> int: + """Approve lines nobody reviewed. Rewrites still need a staff click.""" + week_key = parse_week(week) + if get_week_status(week_key)["postponed"]: + raise WeekPostponed(week_key) + conn = _connect() + try: + with conn, conn.cursor() as cur: + cur.execute( + """ + UPDATE patchnote_bullets + SET status = 'approved', deny_reason = NULL, reviewed_at = now() + WHERE week = %s AND status = 'pending' AND supersedes IS NULL + """, + (week_key,), + ) + return int(cur.rowcount) + finally: + conn.close() diff --git a/backend/src/patchnotes/revise.py b/backend/src/patchnotes/revise.py new file mode 100644 index 00000000..238e4c89 --- /dev/null +++ b/backend/src/patchnotes/revise.py @@ -0,0 +1,122 @@ +"""Turn a staff deny reason into a new pending line. + +The reason stays on the staff review. It is not copied into the public note +unless staff wrote the replacement themselves. A line that should not be +published is left denied. +""" + +from __future__ import annotations + +import re + +from .summarize import player_text + +_EXPLICIT = re.compile( + r"(?is)^(?:please\s+)?(?:say|instead|change(?:\s+it)?\s+to|use|rewrite(?:\s+to)?|post)\s*:\s*(.+)$" +) +_DROP = re.compile( + r"(?i)\b(" + r"don'?t post|do not post|leave (?:it|this) out|drop (?:it|this)|" + r"remove (?:it|this|the line)|not player(?:\s|-)?facing|not for players|" + r"internal only|spoilers?|lore items?|hidden knowledge" + r")\b" +) +_STRIP = re.compile( + r"(?i)(?:don'?t mention|do not mention|remove|without|omit|hide|leave out)\s+(.+)$" +) +_SECTION_INTENT = re.compile( + r"(?i)\b(?:make (?:it|this)|move (?:it|this) to|this is|put (?:it|this) in)\b" +) +_QUOTED = re.compile(r"[\"“”']([^\"“”']{2,80})[\"“”']") +_COMPLAINT = re.compile( + r"(?i)\b(" + r"don'?t|do not|dont|remove|drop|without|spoiler|secret|lore|hidden|" + r"vague|unclear|wrong|leak|leaks|reveal|reveals|mention|shorter|technical|" + r"bugfix|bug fix|internal" + r")\b" +) +_SKIP_PHRASES = frozenset({"it", "this", "that", "the line", "line", "this line"}) + + +def rewrite(section: str, body: str, reason: str) -> tuple[str, str] | None: + """A new section and body, or None when the line should stay denied. + + The result has already passed the player-text check. + """ + text = body.strip() + note = reason.strip() + if not text or not note: + return None + explicit = _explicit_replacement(note) + if explicit is not None: + return _accept(section, text, explicit, note) + if _DROP.search(note): + return None + stripped = _strip_requested(text, note) + candidate = stripped if stripped is not None else text + if candidate == text and _looks_like_replacement(note): + candidate = note + return _accept(section, text, candidate, note) + + +def _accept(section: str, original: str, candidate: str, reason: str) -> tuple[str, str] | None: + cleaned = player_text(candidate) + if cleaned is None: + return None + new_section = _section_of(reason, section) + if cleaned == original and new_section == section: + return None + return new_section, cleaned + + +def _explicit_replacement(reason: str) -> str | None: + match = _EXPLICIT.match(reason.strip()) + if match is None: + return None + text = match.group(1).strip() + return text or None + + +def _looks_like_replacement(reason: str) -> bool: + if _COMPLAINT.search(reason): + return False + return player_text(reason) is not None + + +def _section_of(reason: str, current: str) -> str: + if _SECTION_INTENT.search(reason) is None: + return current + lowered = reason.lower() + if "technical" in lowered: + return "technical" + if "bugfix" in lowered or "bug fix" in lowered or re.search(r"\bfix(?:ed)?\b", lowered): + return "fixed" + if re.search(r"\bnew\b", lowered): + return "new" + if "adjust" in lowered: + return "adjusted" + return current + + +def _strip_requested(body: str, reason: str) -> str | None: + phrases = [item.strip() for item in _QUOTED.findall(reason)] + match = _STRIP.search(reason) + if match is not None: + phrase = match.group(1).strip(" .") + phrase = phrase.split(",")[0].strip() + phrase = re.sub(r"^(?:the|a|an)\s+", "", phrase, flags=re.IGNORECASE) + if phrase.lower() not in _SKIP_PHRASES: + phrases.append(phrase) + if not phrases: + return None + updated = body + for phrase in phrases: + if len(phrase) < 2 or phrase.lower() in _SKIP_PHRASES: + continue + pattern = r"(?i)(? None: db.parse_week("2026-W54") +class NextWeekTest(unittest.TestCase): + def test_steps_into_the_next_iso_week(self) -> None: + self.assertEqual(db.next_week("2026-W39"), "2026-W40") + self.assertEqual(db.next_week("2026-W52"), "2026-W53") + + class CurrentWeekTest(unittest.TestCase): def test_zone_can_move_the_friday_cutoff(self) -> None: # Friday 2026-09-25 10:30 UTC is still morning in UTC and past noon in Berlin. @@ -97,6 +103,7 @@ def test_migrate_creates_table_once(self, mock_connect) -> None: self.assertIn("CREATE TABLE IF NOT EXISTS patchnote_bullets", sql) self.assertIn("CREATE TABLE IF NOT EXISTS patchnote_sources", sql) self.assertIn("CREATE TABLE IF NOT EXISTS patchnote_previews", sql) + self.assertIn("CREATE TABLE IF NOT EXISTS patchnote_week_status", sql) self.assertIn("patchnote_bullets_deny_reason_chk", sql) self.assertIn("status = 'pending'", sql) self.assertTrue(db._MIGRATED) @@ -105,6 +112,20 @@ def test_migrate_creates_table_once(self, mock_connect) -> None: mock_connect.assert_called_once() +class AutoApproveTest(unittest.TestCase): + @mock.patch.dict("os.environ", {"SUPABASE_DB_URL": "postgres://x"}, clear=True) + @mock.patch("patchnotes.db.get_week_status", return_value={"postponed": False}) + @mock.patch("patchnotes.db.psycopg2.connect") + def test_skips_rewritten_lines(self, mock_connect, _status) -> None: + cursor = mock.MagicMock() + cursor.rowcount = 2 + mock_connect.return_value = _make_conn(cursor) + self.assertEqual(db.approve_pending_week("2026-W39"), 2) + sql = cursor.execute.call_args.args[0] + self.assertIn("supersedes IS NULL", sql) + self.assertIn("status = 'pending'", sql) + + class PreviewTest(unittest.TestCase): @mock.patch.dict("os.environ", {"SUPABASE_DB_URL": "postgres://x"}, clear=True) @mock.patch("patchnotes.db.psycopg2.connect") diff --git a/backend/src/patchnotes/test_revise.py b/backend/src/patchnotes/test_revise.py new file mode 100644 index 00000000..8ed5aba9 --- /dev/null +++ b/backend/src/patchnotes/test_revise.py @@ -0,0 +1,54 @@ +"""Deny reasons become a new line, or the line stays denied.""" + +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +_BACKEND_SRC = Path(__file__).resolve().parents[1] +if str(_BACKEND_SRC) not in sys.path: + sys.path.insert(0, str(_BACKEND_SRC)) + +from patchnotes.revise import rewrite # noqa: E402 + + +class RewriteTest(unittest.TestCase): + def test_explicit_wording_replaces_the_line(self) -> None: + section, body = rewrite("new", "Added a station near the vault", "say: Added a station") + self.assertEqual(section, "new") + self.assertEqual(body, "Added a station") + + def test_staff_sentence_is_the_new_line(self) -> None: + section, body = rewrite("adjusted", "Changed a price", "Lowered the station price") + self.assertEqual((section, body), ("adjusted", "Lowered the station price")) + + def test_drop_leaves_the_line_denied(self) -> None: + self.assertIsNone(rewrite("new", "Added a station", "Don't post this")) + self.assertIsNone(rewrite("new", "Named a lore item", "This names a lore item")) + + def test_strips_a_named_detail(self) -> None: + section, body = rewrite( + "new", + "Opened the north wing dungeon", + "Don't mention the dungeon", + ) + self.assertEqual(section, "new") + self.assertNotIn("dungeon", body.lower()) + self.assertIn("north wing", body) + + def test_does_not_cut_through_a_longer_word(self) -> None: + self.assertIsNone( + rewrite("adjusted", "Updated workshop recipes", "Don't mention the shop") + ) + + def test_section_move_keeps_a_safe_line(self) -> None: + section, body = rewrite("new", "Rebuilt a plugin", "Make it technical") + self.assertEqual(section, "technical") + self.assertEqual(body, "Rebuilt a plugin") + + def test_same_line_is_not_posted_again(self) -> None: + self.assertIsNone(rewrite("fixed", "Fixed a door", "Fixed a door")) + + def test_secret_replacement_is_refused(self) -> None: + self.assertIsNone(rewrite("technical", "Rebuilt a plugin", "say: staff_key=abc"))