From 3d3daffd8d67a1b17aa495c844993b67276dfee8 Mon Sep 17 00:00:00 2001 From: George Weale Date: Fri, 14 Aug 2026 10:02:27 -0700 Subject: [PATCH] chore: unwrap model-drafted changelog highlights Co-authored-by: George Weale PiperOrigin-RevId: 964754664 --- scripts/curate_changelog.py | 40 +++++++++- tests/unittests/test_curate_changelog.py | 97 ++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 3 deletions(-) create mode 100644 tests/unittests/test_curate_changelog.py diff --git a/scripts/curate_changelog.py b/scripts/curate_changelog.py index a87ec268..5511be22 100644 --- a/scripts/curate_changelog.py +++ b/scripts/curate_changelog.py @@ -24,7 +24,9 @@ newest version section, in order: landed under several commits), and lowercase the leading word so entries read as consistent imperative phrases. 2. Draft a short "Highlights" block with Gemini and place it above the fold, so - a reader grasps the release in a handful of bullets. + a reader grasps the release in a handful of bullets. The drafted prose is + unwrapped to one line per paragraph and per bullet, because GitHub renders a + single newline as a line break. 3. For large releases, collapse the full categorized list under a ``
`` fold so the notes read short while remaining a complete record. @@ -62,6 +64,11 @@ _MENTION_RE = re.compile(r"\[@([\w-]+)\]\(https://github\.com/\1\)") _LEAD_RE = re.compile( r"(?P\s*\* (?:\*\*[^*]+\*\* )?)(?P\w+)(?P.*)", re.S ) +# Opens a list item. A wrapped continuation is joined onto one of these. +_LIST_ITEM_RE = re.compile(r"^\s*(?:[-*+] |\d+\. )") +# Lines whose meaning depends on standing alone: headers, table rows, block +# quotes, code fences. A wrapped continuation is never joined onto one. +_STANDALONE_RE = re.compile(r"^\s*(?:#{1,6} |[|>]|```)") # Inserted verbatim when the model is unavailable, so the release manager has a # scaffold to fill in by hand. Mirrors the format the model is asked to produce. @@ -96,7 +103,8 @@ Write a short Highlights section so a reader can grasp the release at a glance: the bullets, each with a one-line migration note. Output ONLY the markdown body. Do NOT include the "### Highlights" header and do -NOT wrap the output in code fences. +NOT wrap the output in code fences. Put each paragraph and each bullet on a +single line, however long; do not hard-wrap them. Changelog for the new version: @@ -206,9 +214,35 @@ def _draft_highlights(section_text: str, *, model: str) -> str | None: return None +def _unwrap_lines(text: str) -> str: + """Joins each hard-wrapped paragraph and list item back onto one line. + + Markdown treats a wrapped paragraph as one paragraph, but GitHub does not: + in a pull request body or a release note it renders every newline as a line + break, so prose a model wrapped at 80 columns breaks mid-sentence. Blank + lines, headers, table rows, quotes and fenced blocks keep their own lines. + """ + out: list[str] = [] + fenced = False + for line in text.splitlines(): + stripped = line.strip() + if stripped.startswith("```"): + fenced = not fenced + out.append(line.rstrip()) + elif fenced or not stripped: + out.append(line.rstrip()) + elif _LIST_ITEM_RE.match(line) or _STANDALONE_RE.match(line): + out.append(line.rstrip()) + elif out and out[-1].strip() and not _STANDALONE_RE.match(out[-1]): + out[-1] = f"{out[-1]} {stripped}" + else: + out.append(stripped) + return "\n".join(out) + + def _build_block(body: str) -> str: """Wraps a model-drafted body in the Highlights header.""" - body = body.strip() + body = _unwrap_lines(body).strip() if body.startswith(_HIGHLIGHTS_HEADER): body = body[len(_HIGHLIGHTS_HEADER) :].lstrip("\n") return f"{_HIGHLIGHTS_HEADER}\n\n{body}\n" diff --git a/tests/unittests/test_curate_changelog.py b/tests/unittests/test_curate_changelog.py new file mode 100644 index 00000000..847388fc --- /dev/null +++ b/tests/unittests/test_curate_changelog.py @@ -0,0 +1,97 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for the release changelog curation.""" + +from __future__ import annotations + +import importlib.util +import pathlib +import sys + +_SCRIPT = ( + pathlib.Path(__file__).parent.parent.parent + / "scripts" + / "curate_changelog.py" +) +_SPEC = importlib.util.spec_from_file_location("curate_changelog", _SCRIPT) +curate_changelog = importlib.util.module_from_spec(_SPEC) +sys.modules["curate_changelog"] = curate_changelog +_SPEC.loader.exec_module(curate_changelog) + + +def test_unwrap_joins_a_wrapped_paragraph(): + text = "A release about\ncorrectness and hardening." + + assert ( + curate_changelog._unwrap_lines(text) + == "A release about correctness and hardening." + ) + + +def test_unwrap_joins_a_wrapped_list_item(): + text = "* **Tools**: a tool response now carries\nimages back to the model." + + assert curate_changelog._unwrap_lines(text) == ( + "* **Tools**: a tool response now carries images back to the model." + ) + + +def test_unwrap_keeps_separate_list_items_apart(): + text = "* first item\n* second item\n- third item" + + assert curate_changelog._unwrap_lines(text) == text + + +def test_unwrap_keeps_numbered_list_items_apart(): + text = "1. first step\n2. second step" + + assert curate_changelog._unwrap_lines(text) == text + + +def test_unwrap_keeps_blank_lines_and_headers_on_their_own_lines(): + text = "the theme.\n\n#### Breaking changes\n\n* **X**: migrate by doing Y." + + assert curate_changelog._unwrap_lines(text) == text + + +def test_unwrap_leaves_a_fenced_block_alone(): + text = "install it:\n\n```bash\nuv pip install google-adk\nadk web\n```" + + assert curate_changelog._unwrap_lines(text) == text + + +def test_unwrap_does_not_join_a_paragraph_onto_a_closing_fence(): + text = "```bash\nadk web\n```\nthen open the\nbrowser." + + assert curate_changelog._unwrap_lines(text) == ( + "```bash\nadk web\n```\nthen open the browser." + ) + + +def test_unwrap_does_not_join_a_paragraph_onto_a_table_row(): + text = "| a | b |\nnot a table cell." + + assert curate_changelog._unwrap_lines(text) == text + + +def test_build_block_unwraps_drafted_prose(): + drafted = "A release about\ncorrectness.\n\n* **Tools**: return\nmedia." + + block = curate_changelog._build_block(drafted) + + assert block == ( + "### Highlights\n\nA release about correctness.\n\n" + "* **Tools**: return media.\n" + )