diff --git a/roboco/services/release_proposal.py b/roboco/services/release_proposal.py index cca86af0..ecc10ff7 100644 --- a/roboco/services/release_proposal.py +++ b/roboco/services/release_proposal.py @@ -283,11 +283,17 @@ class ReleaseProposalService(BaseService): publish). Off/no-creds is itself a no-op inside the engine. The proposal task's project scopes the draft to the released project.""" try: - from roboco.services.x_engine import get_x_engine + from roboco.services.x_engine import changelog_highlights, get_x_engine + # Prefer the curated CHANGELOG's feature headlines over raw + # per-commit subjects — the latter made the announcement caption + # parrot the top commit ("docs: curate the Unreleased body…"). + highlights = changelog_highlights(report.drafted_changelog) or list( + report.change_summary + ) await get_x_engine(self.session).draft_release_post( version=report.proposed_version, - highlights=list(report.change_summary), + highlights=highlights, project_id=project_id, ) except Exception as exc: diff --git a/roboco/services/x_engine.py b/roboco/services/x_engine.py index 36f82436..59fbb188 100644 --- a/roboco/services/x_engine.py +++ b/roboco/services/x_engine.py @@ -103,6 +103,31 @@ def _fallback_release_body( return f"{product_name} v{version} is out: {lead}" +# Bold feature leads in a Keep-a-Changelog release body: "- **Headline (#N).**" +_CHANGELOG_LEAD_RE = re.compile(r"^- \*\*(?P.+?)\*\*", re.MULTILINE) +_CHANGELOG_PR_REF_RE = re.compile(r"\s*\(#\d+(?:,\s*#\d+)*\)") + + +def changelog_highlights(entry: str, *, limit: int = 8) -> list[str]: + """Human-readable feature headlines from a curated CHANGELOG release body. + + The release drafter's ``change_summary`` is raw per-commit subjects + ("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. + """ + out: list[str] = [] + for m in _CHANGELOG_LEAD_RE.finditer(entry): + lead = _CHANGELOG_PR_REF_RE.sub("", m.group("lead")).strip().rstrip(".").strip() + if lead: + out.append(lead) + if len(out) >= limit: + break + return out + + def _release_prompt( version: str, highlights: list[str], voice: str, product_name: str ) -> str: diff --git a/tests/unit/services/test_x_engine.py b/tests/unit/services/test_x_engine.py index f39ad48e..d6e19299 100644 --- a/tests/unit/services/test_x_engine.py +++ b/tests/unit/services/test_x_engine.py @@ -1398,3 +1398,27 @@ async def test_draft_release_post_uses_project_name_when_set( assert body is not None assert "Acme Robotics" in body assert "RoboCo" not in body + + +def test_changelog_highlights_extracts_feature_headlines() -> None: + entry = ( + "## [0.26.0] - 2026-07-20\n\n" + "### Security\n\n" + "- **Orchestrator API is off the public internet (GHSA-4f7g).** Both " + "composes published :8000 on 0.0.0.0.\n\n" + "### Added\n\n" + "- **Telegram Mini App V5 — brand voice and an operations ring (#583).** " + "Share Tech Mono becomes the display face.\n" + "- **Forge program: GitHub, Gitea, and GitLab (#575, #581).** One API.\n" + ) + hl = x_engine_module.changelog_highlights(entry) + assert hl[0] == "Orchestrator API is off the public internet (GHSA-4f7g)" + assert hl[1] == "Telegram Mini App V5 — brand voice and an operations ring" + assert hl[2] == "Forge program: GitHub, Gitea, and GitLab" + # No raw commit-subject noise, no trailing PR refs or periods. + assert all("#" not in h.split("(GHSA")[0] for h in hl) + + +def test_changelog_highlights_empty_on_no_leads() -> None: + body = "## [0.26.0]\n\nplain prose, no bold leads\n" + assert x_engine_module.changelog_highlights(body) == []