from __future__ import annotations import json import re import runpy import subprocess import sys from pathlib import Path import pytest SCRIPT = Path(__file__).resolve().parents[2] / ".github/scripts/changelog/compose_notes.py" compose_notes = runpy.run_path(str(SCRIPT))["compose_notes"] REPO = "o/o" CREDITS = [ {"pr": pr, "author": author, "author_url": f"https://github.com/{author}"} for pr, author in [(1, "alice"), (2, "bob"), (3, "carol"), (4, "dave"), (5, "bob")] ] def _raw(highlights: str) -> str: return f"\n{highlights}\n" @pytest.mark.parametrize( "highlights", [ "## Major new features\n\n- New feature (#3, @carol)\n\n" + "## Breaking changes\n\n- Renamed flag (#4, @dave)\n\n" + "## Bug fixes\n\n- Fixed crashes (#1, @alice)", "## Major new features\n\n- New feature (#3, @carol)\n\n" + "## Bug fixes\n\n- Fixed crashes (#1, @alice)", "## Bug fixes\n\n- Fixed crashes (#1, @alice)", ], ) def test_curated_sections_omit_other_contributions(highlights: str) -> None: notes = compose_notes(_raw(highlights), CREDITS, REPO) highlighted = set(re.findall(r"#(\d+)", highlights)) summary, thanks = notes.split("### 💜 Thanks to our community") assert re.findall(r"(?m)^## .+$", summary) == re.findall(r"(?m)^## .+$", highlights) assert summary.count("- ") == highlights.count("- ") for credit in CREDITS: link = f"[#{credit['pr']}](https://github.com/o/o/pull/{credit['pr']})" assert notes.count(link) == int(str(credit["pr"]) in highlighted) assert (link in summary) == (str(credit["pr"]) in highlighted) assert f"[@{credit['author']}]({credit['author_url']})" in thanks assert "/pull/" not in thanks assert "## Other contributions" not in notes assert "## All contributions" not in notes assert notes.endswith("Full Changelog: https://github.com/o/o/blob/main/CHANGELOG.md\n") @pytest.mark.parametrize( "raw", [ "", "Agent failed", _raw(""), _raw("## Highlights\n- Wrong heading (#1)"), _raw("## Bug fixes\n## Major new features\n- Wrong order (#1)"), _raw("## Bug fixes\n## Bug fixes\n- Duplicate headings (#1)"), _raw("## Bug fixes\n- Unknown PR (#999)"), _raw("## Bug fixes\n- Missing PR citation"), _raw("## Bug fixes\n- Fixed (#1)\n\n## Other contributions\n- Other (#2)"), ], ) def test_unavailable_or_invalid_highlights_keep_every_credit(raw: str) -> None: assert compose_notes(raw, CREDITS, REPO) == compose_notes("", CREDITS, REPO) notes = compose_notes(raw, CREDITS, REPO) assert notes.startswith("### 💜 Thanks to our community\n") assert "Other contributions" not in notes assert "/pull/" not in notes for credit in CREDITS: assert f"[@{credit['author']}]({credit['author_url']})" in notes def test_highlight_credits_use_github_authors_and_deduplicate() -> None: notes = compose_notes(_raw("## Bug fixes\n- Fixed issues (#2, #5, #2, @wrong)"), CREDITS, REPO) summary, thanks = notes.split("### 💜 Thanks to our community") assert "@wrong" not in notes assert summary.count("[@bob]") == 1 assert summary.count("/pull/2)") == 1 assert summary.count("/pull/5)") == 1 assert "/pull/" not in thanks def test_all_highlighted_omits_empty_other_section() -> None: notes = compose_notes(_raw("## Bug fixes\n- Fixed (#1)"), CREDITS[:1], REPO) assert "Other contributions" not in notes assert "Full Changelog:" in notes @pytest.mark.parametrize("raw", ["", _raw("## Bug fixes\n- Fixed (#3)")]) def test_community_handles_bots_and_deleted_authors(raw: str) -> None: credits = [ {"pr": 3, "author": "", "author_url": ""}, {"pr": 2, "author": "bot[bot]", "author_url": "https://github.com/apps/bot"}, {"pr": 1, "author": "bot[bot]", "author_url": "https://github.com/apps/bot"}, ] notes = compose_notes(raw, credits, REPO) assert "Author unavailable" not in notes assert "Other contributions" not in notes assert "/pull/1)" not in notes and "/pull/2)" not in notes assert ("/pull/3)" in notes) == bool(raw) assert notes.count("[@bot[bot]](https://github.com/apps/bot)") == 1 @pytest.mark.parametrize("raw", ["", _raw("## Bug fixes\n- Fixed (#1, #2, #3, #4, #5)")]) def test_community_credits_all_authors_once_in_alphabetical_order(raw: str) -> None: credits = [ {"pr": pr, "author": author, "author_url": url} for pr, author, url in [ (1, "zed", "https://github.com/zed"), (2, "alice", "https://github.com/alice"), (3, "Bob", "https://github.com/Bob"), (4, "ALICE", "https://github.com/ALICE"), (5, "", ""), (6, "bot[bot]", "https://github.com/apps/bot"), ] ] notes = compose_notes(raw, credits, REPO) assert notes.count("### 💜 Thanks to our community") == 1 thanks, footer = notes.split("### 💜 Thanks to our community\n\n")[1].split( "\n\nFull Changelog:" ) assert thanks.split("\n\n")[-1] == ( "[@ALICE](https://github.com/ALICE), [@Bob](https://github.com/Bob), " "[@bot[bot]](https://github.com/apps/bot), [@zed](https://github.com/zed)" ) assert "Author unavailable" not in thanks assert footer.strip() == "https://github.com/o/o/blob/main/CHANGELOG.md" def test_community_note_without_known_authors() -> None: notes = compose_notes("", [{"pr": 1, "author": "", "author_url": ""}], REPO) thanks = notes.split("### 💜 Thanks to our community\n\n")[1] assert "Thank you for building omnigent with us" in thanks assert "[@" not in thanks assert "Author unavailable" not in thanks def test_empty_release_keeps_community_note_and_changelog_link() -> None: notes = compose_notes("", [], REPO) assert notes.startswith("### 💜 Thanks to our community\n") assert "Other contributions" not in notes assert "[@" not in notes assert notes.endswith("Full Changelog: https://github.com/o/o/blob/main/CHANGELOG.md\n") def test_cli_missing_highlights_uses_community_fallback(tmp_path: Path) -> None: credits = tmp_path / "credits.json" credits.write_text(json.dumps(CREDITS)) out = tmp_path / "notes.md" result = subprocess.run( [ sys.executable, str(SCRIPT), "--highlights", str(tmp_path / "missing.txt"), "--credits", str(credits), "--repo", REPO, "--out", str(out), ], check=True, capture_output=True, text=True, ) assert out.read_text() == compose_notes("", CREDITS, REPO) assert ( "::warning::Release highlights omitted: drafter output is empty or unavailable" in result.stderr ) @pytest.mark.parametrize( ("raw", "reason"), [ ("Agent failed", "RELEASE_NOTES markers are missing"), (_raw(""), "the RELEASE_NOTES block is empty"), (_raw("## Highlights\n- Fix (#1)"), "highlight headings"), (_raw("## Bug fixes\n- Fixed (#1)\n wrapped text"), "highlight line 3"), ( _raw("## Bug fixes\n- Fixed (#1) with more text"), "must be a bullet ending in PR citations", ), (_raw("## Bug fixes\n- Fixed (#999)"), "cites a PR outside the release"), (_raw("## Bug fixes"), "contains no highlight bullets"), ], ) def test_fallback_warning_explains_reason_and_preserves_credits(raw, reason, capsys) -> None: notes = compose_notes(raw, CREDITS, REPO, warn_on_fallback=True) assert notes == compose_notes("", CREDITS, REPO) warning = capsys.readouterr().err assert "::warning::Release highlights omitted:" in warning assert reason in warning assert "Keeping community thanks and the full changelog link" in warning def test_valid_highlights_and_intentional_fallback_do_not_warn(capsys) -> None: compose_notes(_raw("## Bug fixes\n- Fixed (#1)"), CREDITS, REPO, warn_on_fallback=True) compose_notes("", CREDITS, REPO) assert capsys.readouterr().err == "" def test_repeated_pr_across_highlights_falls_back_with_reason(capsys) -> None: raw = _raw("## Major new features\n- Feature (#1)\n## Bug fixes\n- Fix (#1)") assert compose_notes(raw, CREDITS, REPO, warn_on_fallback=True) == compose_notes( "", CREDITS, REPO ) assert "repeats an already-cited PR" in capsys.readouterr().err def test_cli_invalid_utf8_preserves_community_fallback(tmp_path: Path) -> None: credits = tmp_path / "credits.json" credits.write_text(json.dumps(CREDITS)) highlights = tmp_path / "highlights.txt" highlights.write_bytes(b"\xff") out = tmp_path / "notes.md" result = subprocess.run( [ sys.executable, str(SCRIPT), "--highlights", str(highlights), "--credits", str(credits), "--repo", REPO, "--out", str(out), ], capture_output=True, text=True, check=True, ) assert "RELEASE_NOTES markers are missing" in result.stderr assert out.read_text() == compose_notes("", CREDITS, REPO)