diff --git a/scripts/aggregate_changelog.py b/scripts/aggregate_changelog.py index 190ef29..91928f2 100644 --- a/scripts/aggregate_changelog.py +++ b/scripts/aggregate_changelog.py @@ -24,11 +24,38 @@ def fetch_releases(github_slug, per_page=5): return [] return [ {"tag": r["tag_name"], "date": r["published_at"][:10], - "url": r["html_url"], "body": (r.get("body") or "").strip()[:200]} + "url": r["html_url"], "body": (r.get("body") or "").strip()[:400]} for r in resp.json() if not r.get("draft") ] +def summarize_body(body, tag=""): + """Return a one-line, heading-free summary of a release body. + + GitHub release bodies frequently open with a heading that only restates the + version (for example ``## v1.7.1 (2026-07-19)``). Injected verbatim, that + line duplicates the tag we already print and its leading ``#`` corrupts the + changelog page's heading hierarchy. Skip such redundant leading headings and + return the first real line of notes as inline (heading-free) text. Returns + an empty string when the body carries no notes beyond the version header. + """ + tag_norm = tag.lstrip("vV").strip() + for raw in body.splitlines(): + line = raw.strip() + if not line: + continue + text = line.lstrip("#").strip() + if not text: + continue + if line.startswith("#"): + heading = text.lstrip("vV").strip() + # A leading heading that merely restates the version is noise. + if tag and (tag in text or (tag_norm and heading.startswith(tag_norm))): + continue + return text + return "" + + def aggregate(repos=None, docs_dir=None): repos = repos or load_repos() docs_dir = pathlib.Path(docs_dir or DOCS_DIR) @@ -44,8 +71,8 @@ def aggregate(repos=None, docs_dir=None): lines.append(f"\n## {repo['name']}\n") for r in releases: lines.append(f"- **[{r['tag']}]({r['url']})** ({r['date']})") - if r["body"]: - summary = r["body"].split("\n")[0] + summary = summarize_body(r["body"], r["tag"]) + if summary: lines.append(f" {summary}") out_path = docs_dir / "changelog.md" diff --git a/tests/test_aggregate_changelog.py b/tests/test_aggregate_changelog.py index 9e99139..59f04b5 100644 --- a/tests/test_aggregate_changelog.py +++ b/tests/test_aggregate_changelog.py @@ -7,7 +7,7 @@ sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / "scripts")) -from aggregate_changelog import aggregate, fetch_releases +from aggregate_changelog import aggregate, fetch_releases, summarize_body def test_aggregate_writes_changelog(tmp_path, mocker): @@ -70,6 +70,46 @@ def test_aggregate_handles_no_releases(tmp_path, mocker): assert (tmp_path / "changelog.md").exists() +def test_summarize_body_skips_redundant_version_heading(): + """A leading heading that only restates the version is dropped, and the + first real note is returned as heading-free inline text.""" + body = "## v1.7.1 (2026-07-19)\n\nFixed halt-on-drift regression" + assert summarize_body(body, "v1.7.1") == "Fixed halt-on-drift regression" + + +def test_summarize_body_version_only_returns_empty(): + """When the body is nothing but the version header, there is no note to + render, so aggregate should append nothing rather than echo the heading.""" + assert summarize_body("## v1.7.1 (2026-07-19)", "v1.7.1") == "" + + +def test_summarize_body_strips_leading_hash_from_first_note(): + """A real leading heading (not a version restatement) is rendered inline so + it does not disrupt the changelog page's heading hierarchy.""" + assert summarize_body("# Highlights\nStuff", "v2.0.0") == "Highlights" + + +def test_aggregate_drops_duplicate_version_heading(tmp_path, mocker): + """End-to-end: the changelog must not contain an echoed `## vX` heading + line, which previously duplicated the tag and broke heading hierarchy.""" + mock_releases = [ + {"tag_name": "v1.7.1", "published_at": "2026-07-19T00:00:00Z", + "html_url": "https://github.com/OpenAdaptAI/test/releases/v1.7.1", + "body": "## v1.7.1 (2026-07-19)\n\nHalt-on-drift fix", "draft": False}, + ] + mock_resp = mocker.Mock() + mock_resp.status_code = 200 + mock_resp.json.return_value = mock_releases + mocker.patch("aggregate_changelog.requests.get", return_value=mock_resp) + + repos = [{"name": "test-pkg", "github": "OpenAdaptAI/test-pkg", "changelog": True}] + aggregate(repos=repos, docs_dir=tmp_path) + content = (tmp_path / "changelog.md").read_text() + + assert "## v1.7.1 (2026-07-19)" not in content + assert "Halt-on-drift fix" in content + + def test_fetch_releases_handles_http_error(mocker): mock_resp = mocker.Mock() mock_resp.status_code = 500