mirror of
https://github.com/penpot/penpot.git
synced 2026-11-05 09:30:38 +00:00
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
384 lines
13 KiB
Python
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)
|