penpot/scripts/test_changelog.py
Andrey Antukh 68c85c3c69 ✨ Add changelog flow memory and breaking-change generator
Add a `boom` subcommand that generates the `💥` section of CHANGES.md
from the closed, non-excluded milestone issues labelled `breaking change`,
using the last `## Breaking change note` comment of each issue. The whole
comment is copied (headings demoted to `#####` outside code fences), and
each issue becomes a `#### <title>` block with the issue and merged
closing PR references on their own line. `--write` replaces only that
subsection and is idempotent.

Add a `comments` subcommand to gh.py (REST, paginated) so the generator can
read issue comments, and report anomaly type G (`breaking change` issue
missing from `💥`), mirroring the `release highlight` check.

Document the flow in `mem:workflow/updating-changelog` (also readable by
release managers, like `mem:audit-log`), turn the `update-changelog` skill
into a router to it, and update the references in `critical-info`,
`creating-issues` and the scripts memories.

AI-assisted-by: deepseek-v4.1-flash
2026-11-03 22:31:27 +00:00

384 lines
13 KiB
Python

#!/usr/bin/env python3
"""Tests for scripts/changelog.py.
Run with:
python3 scripts/test_changelog.py
"""
import argparse
import contextlib
import importlib.machinery
import importlib.util
import io
import json
import pathlib
import sys
import tempfile
import unittest
from unittest.mock import patch
# Loading scripts/changelog.py should not emit scripts/__pycache__/.
sys.dont_write_bytecode = True
SCRIPT_PATH = pathlib.Path(__file__).resolve().parent / "changelog.py"
def load_changelog():
"""Load scripts/changelog.py as a module without running its CLI."""
loader = importlib.machinery.SourceFileLoader("changelog_helper", str(SCRIPT_PATH))
spec = importlib.util.spec_from_loader("changelog_helper", loader)
module = importlib.util.module_from_spec(spec)
loader.exec_module(module)
return module
ch = load_changelog()
CHANGES = """# CHANGELOG
## 2.19.0 (Unreleased)
### :boom: Breaking changes & Deprecations
- Old entry.
### :rocket: Epics and highlights
- Add highlights [#1](https://github.com/penpot/penpot/issues/1)
"""
class ExtractBreakingNoteTests(unittest.TestCase):
def test_uses_the_whole_comment_after_the_marker(self):
body = (
"## Breaking change note\n\n"
"First paragraph.\n\n"
"- bullet one\n"
"- bullet two\n\n"
"```sql\nSELECT 1;\n```\n"
)
self.assertEqual(
ch.extract_breaking_note(body),
"First paragraph.\n\n- bullet one\n- bullet two\n\n"
"```sql\nSELECT 1;\n```",
)
def test_accepts_any_heading_level_for_the_marker(self):
self.assertEqual(
ch.extract_breaking_note("# breaking change note\n\nText."), "Text."
)
self.assertEqual(
ch.extract_breaking_note("### Breaking Change Note\n\nText."), "Text."
)
def test_returns_none_without_marker(self):
self.assertIsNone(ch.extract_breaking_note("### Description\n\ntext\n"))
self.assertIsNone(ch.extract_breaking_note("## Breaking change notes\n\ntext"))
self.assertIsNone(ch.extract_breaking_note(None))
self.assertIsNone(ch.extract_breaking_note(""))
def test_returns_none_for_a_marker_without_content(self):
self.assertIsNone(ch.extract_breaking_note("## Breaking change note\n"))
class DemoteHeadingsTests(unittest.TestCase):
def test_demotes_headings_below_the_entry_level(self):
markdown = "# One\n## Two\n### Three\n#### Four\n##### Five\n"
self.assertEqual(
ch.demote_headings(markdown),
"##### One\n##### Two\n##### Three\n##### Four\n##### Five",
)
def test_keeps_headings_inside_code_fences(self):
markdown = (
"```bash\n# not a heading\n```\n"
"## Real heading\n"
"~~~\n### not a heading\n~~~\n"
)
self.assertEqual(
ch.demote_headings(markdown),
"```bash\n# not a heading\n```\n"
"##### Real heading\n"
"~~~\n### not a heading\n~~~",
)
class StripTitlePrefixTests(unittest.TestCase):
def test_strips_shortcodes(self):
self.assertEqual(ch.strip_title_prefix(":boom: Enable it"), "Enable it")
self.assertEqual(ch.strip_title_prefix(":bug: :tada: Fix it"), "Fix it")
self.assertEqual(ch.strip_title_prefix("Plain title"), "Plain title")
class IssueExcludedTests(unittest.TestCase):
def issue(self, **overrides):
base = {
"state": "CLOSED",
"issue_type": "Enhancement",
"project_status": "In progress",
"labels": [],
}
base.update(overrides)
return base
def test_keeps_a_valid_closed_issue(self):
self.assertFalse(ch.issue_excluded(self.issue()))
def test_excludes_open_task_rejected_and_labelled(self):
self.assertTrue(ch.issue_excluded(None))
self.assertTrue(ch.issue_excluded(self.issue(state="OPEN")))
self.assertTrue(ch.issue_excluded(self.issue(issue_type="Task")))
self.assertTrue(ch.issue_excluded(self.issue(project_status="Rejected")))
self.assertTrue(ch.issue_excluded(self.issue(labels=["release blocker"])))
self.assertTrue(ch.issue_excluded(self.issue(labels=["no changelog"])))
class RenderBoomEntryTests(unittest.TestCase):
def test_entry_is_a_heading_plus_note_plus_references(self):
issue = {"number": 7, "title": ":boom: Drop the flag", "labels": []}
self.assertEqual(
ch.render_boom_entry(issue, "One.\n\nTwo.", [8, 9]),
"#### Drop the flag\n\nOne.\n\nTwo.\n\n"
"[#7](https://github.com/penpot/penpot/issues/7)"
" (PR: [#8](https://github.com/penpot/penpot/pull/8),"
" [#9](https://github.com/penpot/penpot/pull/9))",
)
def test_entry_without_a_note_keeps_the_heading(self):
issue = {"number": 7, "title": "Drop the flag", "labels": []}
self.assertEqual(
ch.render_boom_entry(issue, None, []),
"#### Drop the flag\n\n[#7](https://github.com/penpot/penpot/issues/7)",
)
def test_community_attribution_goes_before_the_issue_link(self):
issue = {"number": 7, "title": "Drop the flag", "labels": []}
entry = ch.render_boom_entry(issue, "Text.", [8], "someone")
self.assertEqual(
entry,
"#### Drop the flag\n\nText.\n\n(by @someone)"
" [#7](https://github.com/penpot/penpot/issues/7)"
" (PR: [#8](https://github.com/penpot/penpot/pull/8))",
)
class ReplaceBoomSectionTests(unittest.TestCase):
def test_replaces_the_existing_subsection(self):
entry = "#### New entry\n\nSome text.\n\n[#9](https://example.test/9)"
updated = ch.replace_boom_section(CHANGES, "2.19.0", [entry])
self.assertIn(
"### :boom: Breaking changes & Deprecations\n\n"
f"{entry}\n\n### :rocket:",
updated,
)
self.assertNotIn("Old entry.", updated)
def test_replacement_is_idempotent(self):
entry = "#### New entry\n\nSome text.\n\n[#9](https://example.test/9)"
once = ch.replace_boom_section(CHANGES, "2.19.0", [entry])
self.assertEqual(once, ch.replace_boom_section(once, "2.19.0", [entry]))
def test_empty_section_keeps_the_heading(self):
updated = ch.replace_boom_section(CHANGES, "2.19.0", [])
self.assertIn(
"### :boom: Breaking changes & Deprecations\n\n### :rocket:", updated
)
def test_inserts_the_subsection_when_missing(self):
without = CHANGES.replace(
"### :boom: Breaking changes & Deprecations\n\n- Old entry.\n\n", ""
)
updated = ch.replace_boom_section(without, "2.19.0", ["#### New entry"])
self.assertIn(
"## 2.19.0 (Unreleased)\n\n"
"### :boom: Breaking changes & Deprecations\n\n#### New entry\n\n"
"### :rocket:",
updated,
)
def test_unknown_version_raises(self):
with self.assertRaises(ValueError):
ch.replace_boom_section(CHANGES, "9.9.9", ["#### Entry"])
ISSUES = [
{
"number": 21,
"title": ":boom: New default",
"state": "CLOSED",
"issue_type": "Enhancement",
"labels": ["breaking change"],
"closing_prs": [31],
"project_status": "Done",
},
{
"number": 22,
"title": "No note",
"state": "CLOSED",
"issue_type": "Enhancement",
"labels": ["breaking change"],
"closing_prs": [],
"project_status": "Done",
},
{
"number": 23,
"title": "Still open",
"state": "OPEN",
"issue_type": "Enhancement",
"labels": ["breaking change"],
"closing_prs": [],
"project_status": "In progress",
},
{
"number": 24,
"title": "Not breaking",
"state": "CLOSED",
"issue_type": "Bug",
"labels": [],
"closing_prs": [],
"project_status": "Done",
},
]
COMMENTS = [
{"issue": 21, "body": "## Breaking change note\n\nDeploy the new service.\n"},
{"issue": 22, "body": "## Description\n\nNo marker here.\n"},
{"issue": 21, "body": "## Breaking change note\n\nDeploy the new service.\n"},
]
PRS = [{"number": 31, "author": "someone", "labels": [], "state": "MERGED"}]
class CmdBoomTests(unittest.TestCase):
def setUp(self):
self.calls = []
self.issues = ISSUES
self.comments = COMMENTS
self.prs = PRS
def fake_run_gh_py(*args, input_text=None):
self.calls.append(args)
if args[0] == "issues":
return json.dumps(self.issues)
if args[0] == "comments":
return json.dumps(self.comments)
if args[0] == "prs":
return json.dumps(self.prs)
raise AssertionError(f"unexpected gh.py call: {args}")
patcher = patch.object(ch, "run_gh_py", side_effect=fake_run_gh_py)
patcher.start()
self.addCleanup(patcher.stop)
def run_boom(self, **kwargs):
args = argparse.Namespace(
milestone="2.19.0",
changes=CHANGES if "changes" not in kwargs else kwargs["changes"],
write=kwargs.get("write", False),
)
out, err = io.StringIO(), io.StringIO()
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
ch.cmd_boom(args)
return out.getvalue(), err.getvalue()
def test_renders_only_closed_breaking_issues(self):
out, err = self.run_boom()
expected = (
"### :boom: Breaking changes & Deprecations\n\n"
"#### New default\n\n"
"Deploy the new service.\n\n"
"[#21](https://github.com/penpot/penpot/issues/21)"
" (PR: [#31](https://github.com/penpot/penpot/pull/31))\n\n"
"#### No note\n\n"
"[#22](https://github.com/penpot/penpot/issues/22)\n"
)
self.assertEqual(out, expected)
self.assertIn("issue #22 has no", err)
self.assertIn("using the last one", err)
def test_unmerged_closing_pr_is_not_referenced(self):
self.prs = [{"number": 31, "author": "someone", "state": "CLOSED"}]
out, _ = self.run_boom()
self.assertNotIn("pull/31", out)
self.assertIn("[#21](https://github.com/penpot/penpot/issues/21)", out)
def test_no_labelled_issue_clears_the_section_on_write(self):
self.issues = [ISSUES[3]]
with tempfile.TemporaryDirectory() as tmp:
path = pathlib.Path(tmp) / "CHANGES.md"
path.write_text(CHANGES)
out, err = self.run_boom(changes=str(path), write=True)
updated = path.read_text()
self.assertEqual(out, "### :boom: Breaking changes & Deprecations\n")
self.assertNotIn("Old entry.", updated)
self.assertIn(":boom: section updated", err)
def test_writes_the_section_in_place(self):
with tempfile.TemporaryDirectory() as tmp:
path = pathlib.Path(tmp) / "CHANGES.md"
path.write_text(CHANGES)
out, err = self.run_boom(changes=str(path), write=True)
updated = path.read_text()
self.assertIn("#### New default", updated)
self.assertNotIn("Old entry.", updated)
self.assertIn(":boom: section updated", err)
self.assertIn("Deploy the new service.", out)
class ReportTests(unittest.TestCase):
"""Focused check of the `:boom:` sync anomaly (type G) in the report."""
def test_reports_breaking_issue_missing_from_boom(self):
issues = [
{
"number": 1,
"title": "Unlabelled entry",
"state": "CLOSED",
"issue_type": "Enhancement",
"labels": [],
"closing_prs": [],
"project_status": "Done",
},
{
"number": 2,
"title": "Labelled, not listed",
"state": "CLOSED",
"issue_type": "Enhancement",
"labels": ["breaking change"],
"closing_prs": [],
"project_status": "Done",
},
]
changes = (
"# CHANGELOG\n\n## 2.19.0 (Unreleased)\n\n"
"### :boom: Breaking changes & Deprecations\n\n"
"#### Something changed\n\n"
"[#1](https://github.com/penpot/penpot/issues/1)\n"
)
with tempfile.TemporaryDirectory() as tmp:
changes_path = pathlib.Path(tmp) / "CHANGES.md"
output_path = pathlib.Path(tmp) / "ISSUES.md"
changes_path.write_text(changes)
args = argparse.Namespace(
milestone="2.19.0",
changes=str(changes_path),
output=str(output_path),
)
with patch.object(ch, "run_gh_py", return_value=json.dumps(issues)), \
patch.object(ch, "fetch_milestone_prs", return_value=[]), \
contextlib.redirect_stdout(io.StringIO()), \
contextlib.redirect_stderr(io.StringIO()):
ch.cmd_report(args)
report = output_path.read_text()
self.assertIn("**:boom: out of sync with breaking change label:** 1", report)
self.assertIn("### :boom: out of sync with breaking change label", report)
self.assertIn("issues/2", report)
self.assertIn("**:boom: entry without breaking change label:** 1", report)
if __name__ == "__main__":
unittest.main(verbosity=2)