diff --git a/CHANGELOG.md b/CHANGELOG.md index c3a24a35..6f105f7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Fixed +- **release-post drafts stop parroting changelog bullets.** The X announcement drafter fed the changelog's bold leads to the local model in document order — Security first, so the WAF plumbing line became the tweeted headline — and its deterministic fallback template quoted the first raw bullet verbatim. Highlights now reorder to marketing order (Added/Changed features before Fixed/Security plumbing), the prompt bans verbatim highlight copying and internal jargon, and the fallback is a generic ships-announcement that can never quote a bullet. - **docs-redirects-only commits fire a CI run so the release gate gets a verdict.** The CI paths filter covered docs/panel/motion-only slave commits but not `docs-redirects/**`, so a redirect-stub commit landing as the slave tip produced no CI run at all — and the fail-closed release-readiness gate read the missing conclusion as "unknown", silently holding back every release proposal until an unrelated code commit happened to land. - **Mix-mode routing gained its escape hatch (#663).** A fully-pinned fleet had no way back to a global mode: mode switches deliberately spare per-agent pins, but the server refused an empty mix map and the panel refused an empty save — so with every agent pinned, the mode buttons were permanent no-ops behind a success toast and the mode label read "mix" forever. An empty per-agent map is now the explicit clear-all, Save mix with nothing picked confirms and clears instead of erroring, a Clear-all button resets every agent to inherit-global in one click, and the Routing-mode section warns when per-agent overrides outrank the mode buttons. - **The lazy DB engine rebinds per event loop (#663).** The global engine cache was loop-agnostic while its pooled connections are loop-bound, so a second event loop touching it (a test's `asyncio.run`, a second server thread, the eval bench's disposable stack) eventually drew a foreign-loop connection and died with "Future attached to a different loop" — the eval-bench smoke's flaky CI timeout. The engine/factory accessors now stamp the owning loop and discard-and-rebuild on cross-loop access; production runs one loop, so nothing changes there. diff --git a/roboco/services/x_engine.py b/roboco/services/x_engine.py index 6850d143..3827eb30 100644 --- a/roboco/services/x_engine.py +++ b/roboco/services/x_engine.py @@ -169,11 +169,13 @@ def _clamp_tweet(text: str) -> str: return collapsed[: MAX_TWEET_CHARS - 1].rstrip() + "…" -def _fallback_release_body( - version: str, highlights: list[str], product_name: str -) -> str: - lead = highlights[0] if highlights else "assorted improvements" - return f"{product_name} v{version} is out: {lead}" +def _fallback_release_body(version: str, product_name: str) -> str: + # Deliberately generic: the deterministic fallback must never quote a raw + # changelog bullet — internal plumbing jargon read as the release headline. + return ( + f"{product_name} v{version} just shipped — new features, fixes, and " + "performance work across the board. Full release notes on GitHub." + ) # Bold feature leads in a Keep-a-Changelog release body: "- **Headline (#N).**" @@ -186,6 +188,13 @@ _CHANGELOG_REF_RE = re.compile( ) +# Marketing order for the announcement prompt. The drafting model anchors on +# highlight #1, and a Keep-a-Changelog body opens with Security — document +# order once tweeted a WAF internals bullet as the release headline. +_SECTION_ORDER = ("added", "changed", "performance", "fixed", "security") +_SECTION_SPLIT_RE = re.compile(r"^### +(?P[A-Za-z ]+?)\s*$", re.MULTILINE) + + def changelog_highlights(entry: str, *, limit: int = 8) -> list[str]: """Human-readable feature headlines from a curated CHANGELOG release body. @@ -193,17 +202,33 @@ def changelog_highlights(entry: str, *, limit: int = 8) -> list[str]: ("docs: curate the full 0.26.0 Unreleased body (#601)") — feeding those to the announcement model produces a lame parroted caption. The curated changelog's bold leads ARE the feature story ("Telegram Mini App V5 — brand - voice…"), so use those instead. Pure + best-effort: a body with no bold - leads yields an empty list and the caller falls back to change_summary. + voice…"), so use those instead — reordered so Added/Changed feature + headlines precede Fixed/Security plumbing. Pure + best-effort: a body with + no bold leads yields an empty list and the caller falls back to + change_summary. """ - out: list[str] = [] - for m in _CHANGELOG_LEAD_RE.finditer(entry): - lead = _CHANGELOG_REF_RE.sub("", m.group("lead")).strip().rstrip(".").strip() - if lead: - out.append(lead) - if len(out) >= limit: - break - return out + + def leads(chunk: str) -> list[str]: + found: list[str] = [] + for m in _CHANGELOG_LEAD_RE.finditer(chunk): + lead = ( + _CHANGELOG_REF_RE.sub("", m.group("lead")).strip().rstrip(".").strip() + ) + if lead: + found.append(lead) + return found + + parts = _SECTION_SPLIT_RE.split(entry) + if len(parts) == 1: # no section headings — keep document order + return leads(entry)[:limit] + rank = {name: i for i, name in enumerate(_SECTION_ORDER)} + sections = [ + (rank.get(parts[i].strip().lower(), len(rank)), leads(parts[i + 1])) + for i in range(1, len(parts) - 1, 2) + ] + sections.sort(key=lambda s: s[0]) # stable: unknown sections keep doc order + ordered = leads(parts[0]) + [lead for _, chunk in sections for lead in chunk] + return ordered[:limit] def _release_prompt( @@ -214,11 +239,14 @@ def _release_prompt( f"{voice}\n\n" f"Draft ONE tweet (max 280 characters) announcing that {product_name} " f"v{version} just shipped. Lead with the single most user-visible " - f"change: name both it and {product_name} in the first sentence. One " - "concrete detail (a real feature name, a real number) beats three " - "vague adjectives. Aim well under 240 characters so the 280 clamp " - "never has to truncate mid-sentence. End on substance, not a " - "slogan.\n\n" + f"change: name both it and {product_name} in the first sentence. " + "Rewrite highlights in plain language a non-engineer follows — NEVER " + "copy a highlight's wording verbatim, and skip internal plumbing " + "jargon (middleware, proxy hops, branch mechanics, CI) unless it IS " + "the user-facing story. One concrete detail (a real feature name, a " + "real number) beats three vague adjectives. Aim well under 240 " + "characters so the 280 clamp never has to truncate mid-sentence. End " + "on substance, not a slogan.\n\n" f"Highlights:\n{bullets}\n" ) @@ -496,9 +524,7 @@ class XEngine(BaseService): error=str(exc), ) draft = None - body = (draft or "").strip() or _fallback_release_body( - version, highlights, product_name - ) + body = (draft or "").strip() or _fallback_release_body(version, product_name) return _clamp_tweet(body) # ---- mentions (periodic poll) ------------------------------------------ diff --git a/tests/unit/services/test_x_engine.py b/tests/unit/services/test_x_engine.py index 835c12c6..b3190cf3 100644 --- a/tests/unit/services/test_x_engine.py +++ b/tests/unit/services/test_x_engine.py @@ -1696,11 +1696,13 @@ def test_changelog_highlights_extracts_feature_headlines() -> None: "- **Forge program: GitHub, Gitea, and GitLab (#575, #581).** One API.\n" ) hl = x_engine_module.changelog_highlights(entry) - # A GHSA advisory ref is stripped exactly like a PR ref — neither belongs - # in a caption prompt's feature headline. - assert hl[0] == "Orchestrator API is off the public internet" - assert hl[1] == "Telegram Mini App V5 — brand voice and an operations ring" - assert hl[2] == "Forge program: GitHub, Gitea, and GitLab" + # Marketing order: Added feature headlines precede the Security plumbing + # even though Security comes first in the document — the drafting model + # anchors on highlight #1. A GHSA advisory ref is stripped exactly like a + # PR ref — neither belongs in a caption prompt's feature headline. + assert hl[0] == "Telegram Mini App V5 — brand voice and an operations ring" + assert hl[1] == "Forge program: GitHub, Gitea, and GitLab" + assert hl[2] == "Orchestrator API is off the public internet" # No raw commit-subject noise, no trailing PR/GHSA refs or periods. assert all("#" not in h and "GHSA" not in h for h in hl)