diff --git a/src/pkgmgr/actions/release/files/changelog_lint.py b/src/pkgmgr/actions/release/files/changelog_lint.py new file mode 100644 index 0000000..5fa5335 --- /dev/null +++ b/src/pkgmgr/actions/release/files/changelog_lint.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +import os +import re +import shutil +import subprocess +import tempfile + +_HEADING = re.compile(r"^\s*#{1,6}\s+(.*?)\s*#*\s*$") +_INLINE_CODE = re.compile(r"`([^`\n]+)`") +_FINDING = re.compile(r"\bMD\d{3}\b") +_MULTI_BLANK = re.compile(r"\n{3,}") + + +class ChangelogLintError(RuntimeError): + """Raised when a changelog entry cannot be made markdown-lint clean.""" + + +def transform_changelog_message(text: str) -> str: + """Normalise a free-form release message into the changelog house style. + + A leading ``#`` heading becomes a bold line of its own (markdown + headings inside an entry body would collide with the ``## [version]`` + structure), and inline ``code`` spans become ``*italic*`` so the entry + stays free of backticks. + """ + text = _INLINE_CODE.sub(r"*\1*", text) + + out: list[str] = [] + for line in text.split("\n"): + heading = _HEADING.match(line) + if heading: + if out and out[-1].strip(): + out.append("") + out.append(f"**{heading.group(1).strip()}**") + out.append("") + else: + out.append(line) + + joined = _MULTI_BLANK.sub("\n\n", "\n".join(out)) + return joined.strip() + + +def lint_changelog_entry(reference_path: str, entry: str) -> list[str]: + """Return markdown-lint findings for *entry* (empty list when clean). + + The entry is checked inside a minimal ``# Changelog`` document so the + result reflects only the entry, not pre-existing issues in the target + file. ``markdownlint-cli2`` is used when available (picking up the + target repository's own config, i.e. the same rules the repo enforces); + otherwise a small built-in check runs. + """ + document = f"# Changelog\n\n{entry.strip()}\n" + if shutil.which("markdownlint-cli2"): + return _markdownlint(reference_path, document) + return _builtin_lint(document) + + +def _markdownlint(reference_path: str, document: str) -> list[str]: + directory = os.path.dirname(os.path.abspath(reference_path)) or "." + fd, tmp_path = tempfile.mkstemp( + suffix=".md", prefix=".pkgmgr-changelog-", dir=directory + ) + name = os.path.basename(tmp_path) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(document) + proc = subprocess.run( + ["markdownlint-cli2", name], + cwd=directory, + capture_output=True, + text=True, + ) + if proc.returncode == 0: + return [] + output = f"{proc.stdout or ''}{proc.stderr or ''}" + findings = [ + line.strip().replace(name, "changelog entry") + for line in output.splitlines() + if _FINDING.search(line) + ] + return findings or [output.strip() or "markdown-lint reported an error"] + finally: + try: + os.remove(tmp_path) + except OSError: + pass + + +def _builtin_lint(document: str) -> list[str]: + errors: list[str] = [] + for number, line in enumerate(document.split("\n"), start=1): + if line != line.rstrip(): + errors.append(f"line {number}: trailing whitespace (MD009)") + if "`" in line: + errors.append(f"line {number}: backtick is not allowed (use *italic*)") + if "\n\n\n" in document: + errors.append("multiple consecutive blank lines (MD012)") + return errors diff --git a/src/pkgmgr/actions/release/files/changelog_md.py b/src/pkgmgr/actions/release/files/changelog_md.py index b3621af..af50f15 100644 --- a/src/pkgmgr/actions/release/files/changelog_md.py +++ b/src/pkgmgr/actions/release/files/changelog_md.py @@ -2,9 +2,15 @@ from __future__ import annotations import os import re +import sys from datetime import date from typing import Optional +from .changelog_lint import ( + ChangelogLintError, + lint_changelog_entry, + transform_changelog_message, +) from .editor import _open_editor_for_changelog H1_RE = re.compile(r"^#\s+\S", re.MULTILINE) @@ -60,31 +66,49 @@ def update_changelog( """ today = date.today().isoformat() - if message is None: - if preview: - message = "Automated release." - else: - print( - "\n[INFO] No release message provided, opening editor for changelog entry...\n" + def _entry_for(raw: str) -> tuple[str, str]: + body = transform_changelog_message(raw).strip() or f"Release {new_version}" + return body, f"## [{new_version}] - {today}\n\n{body}\n\n" + + def _print_findings(findings: list[str]) -> None: + print("\n[ERROR] Changelog entry is not markdown-lint clean:") + for finding in findings: + print(f" - {finding}") + print() + + if message is not None: + body, entry = _entry_for(message) + findings = lint_changelog_entry(changelog_path, entry) + if findings: + _print_findings(findings) + raise ChangelogLintError( + "Provided changelog message is not markdown-lint clean." ) - editor_message = _open_editor_for_changelog() - if not editor_message: - message = "Automated release." - else: - message = editor_message - - body = message.strip() if message and message.strip() else f"Release {new_version}." - entry = f"## [{new_version}] - {today}\n\n{body}\n\n" + elif preview or not sys.stdin.isatty(): + body, entry = _entry_for(message or f"Release {new_version}") + else: + attempt: Optional[str] = None + while True: + print( + "\n[INFO] Provide the changelog entry — a leading '#' becomes " + "bold, `code` becomes italic.\n" + ) + raw = _open_editor_for_changelog(attempt) + body, entry = _entry_for(raw or f"Release {new_version}") + findings = lint_changelog_entry(changelog_path, entry) + if not findings: + break + _print_findings(findings) + attempt = body + print("[INFO] Re-opening the editor so you can fix the entry...") + changelog = "" if os.path.exists(changelog_path): try: with open(changelog_path, "r", encoding="utf-8") as f: changelog = f.read() except Exception as exc: print(f"[WARN] Could not read existing CHANGELOG.md: {exc}") - changelog = "" - else: - changelog = "" new_changelog = _insert_after_h1(changelog, entry) @@ -94,10 +118,10 @@ def update_changelog( if preview: print(f"[PREVIEW] Would insert new entry for {new_version} into CHANGELOG.md") - return message + return body with open(changelog_path, "w", encoding="utf-8") as f: f.write(new_changelog) print(f"Updated CHANGELOG.md with version {new_version}") - return message + return body diff --git a/src/pkgmgr/actions/release/files/editor.py b/src/pkgmgr/actions/release/files/editor.py index 3f28686..fd54018 100644 --- a/src/pkgmgr/actions/release/files/editor.py +++ b/src/pkgmgr/actions/release/files/editor.py @@ -16,9 +16,10 @@ def _open_editor_for_changelog(initial_message: Optional[str] = None) -> str: ) as tmp: tmp_path = tmp.name tmp.write( - "# Write the changelog entry for this release.\n" - "# Lines starting with '#' will be ignored.\n" - "# Empty result will fall back to a generic message.\n\n" + "; Write the changelog entry for this release.\n" + "; Lines starting with ';' are ignored.\n" + "; A leading '#' becomes bold; `code` becomes italic.\n" + "; Empty result will fall back to a generic message.\n\n" ) if initial_message: tmp.write(initial_message.strip() + "\n") @@ -41,5 +42,5 @@ def _open_editor_for_changelog(initial_message: Optional[str] = None) -> str: except OSError: pass - lines = [line for line in content.splitlines() if not line.strip().startswith("#")] + lines = [line for line in content.splitlines() if not line.strip().startswith(";")] return "\n".join(lines).strip() diff --git a/src/pkgmgr/actions/release/workflow.py b/src/pkgmgr/actions/release/workflow.py index e202563..5888fcd 100644 --- a/src/pkgmgr/actions/release/workflow.py +++ b/src/pkgmgr/actions/release/workflow.py @@ -88,9 +88,8 @@ def _release_impl( print("[INFO] No RPM spec file found. Skipping spec version update.") effective_message: Optional[str] = message - if effective_message is None and isinstance(changelog_message, str): - if changelog_message.strip(): - effective_message = changelog_message.strip() + if isinstance(changelog_message, str) and changelog_message.strip(): + effective_message = changelog_message.strip() package_name = resolve_package_name(paths) diff --git a/tests/unit/pkgmgr/actions/release/test_changelog_lint.py b/tests/unit/pkgmgr/actions/release/test_changelog_lint.py new file mode 100644 index 0000000..6cf092b --- /dev/null +++ b/tests/unit/pkgmgr/actions/release/test_changelog_lint.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import unittest + +from pkgmgr.actions.release.files.changelog_lint import ( + lint_changelog_entry, + transform_changelog_message, +) + + +class TestTransformChangelogMessage(unittest.TestCase): + def test_heading_becomes_bold(self) -> None: + out = transform_changelog_message("# Title\n\nbody") + self.assertIn("**Title**", out) + self.assertNotIn("# Title", out) + + def test_multi_hash_heading_becomes_bold(self) -> None: + self.assertEqual( + transform_changelog_message("### Sub heading"), "**Sub heading**" + ) + + def test_inline_code_becomes_italic(self) -> None: + out = transform_changelog_message("use `pkgmgr release` now") + self.assertEqual(out, "use *pkgmgr release* now") + self.assertNotIn("`", out) + + def test_plain_message_is_unchanged(self) -> None: + self.assertEqual(transform_changelog_message("just text"), "just text") + + def test_no_heading_or_backtick_survives(self) -> None: + out = transform_changelog_message("# Heading\n\n* item with `code`") + self.assertNotIn("`", out) + for line in out.split("\n"): + self.assertFalse(line.lstrip().startswith("#")) + + +class TestLintChangelogEntry(unittest.TestCase): + def test_clean_entry_has_no_findings(self) -> None: + entry = "## [1.0.0] - 2026-01-01\n\n* a clean bullet\n\n" + self.assertEqual(lint_changelog_entry("CHANGELOG.md", entry), []) + + +if __name__ == "__main__": # pragma: no cover + unittest.main() diff --git a/tests/unit/pkgmgr/actions/release/test_files.py b/tests/unit/pkgmgr/actions/release/test_files.py index b094ecf..552a08a 100644 --- a/tests/unit/pkgmgr/actions/release/test_files.py +++ b/tests/unit/pkgmgr/actions/release/test_files.py @@ -321,6 +321,24 @@ class TestUpdateChangelog(unittest.TestCase): self.assertIn("\n\n* Provided bullet\n", content) self.assertNotIn("* * Provided bullet", content) + def test_update_changelog_transforms_heading_and_inline_code(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "CHANGELOG.md") + update_changelog( + path, + "1.2.3", + message="# Summary\n\n* uses `foo` tool", + preview=False, + ) + + with open(path, "r", encoding="utf-8") as f: + content = f.read() + + self.assertIn("**Summary**", content) + self.assertIn("*foo*", content) + self.assertNotIn("# Summary", content) + self.assertNotIn("`foo`", content) + def test_update_changelog_preview_does_not_write(self) -> None: with tempfile.TemporaryDirectory() as tmpdir: path = os.path.join(tmpdir, "CHANGELOG.md")