From eed0a04a6b99a8ee7b229948cff69068f5a3ae12 Mon Sep 17 00:00:00 2001 From: Daniel Edgy Edgecombe Date: Sat, 2 Mar 2024 13:23:36 +0000 Subject: [PATCH] feat(extractor): Extract authors footer from commit logs (#97) Convert extracted dict into a dataclass and capture additional Authors footer Refs: #76 Authors: (edgy) --- README.md | 1 + changelog_gen/extractor.py | 112 ++++++++++++++++++++++++------------- changelog_gen/writer.py | 15 +++-- tests/test_extractor.py | 84 ++++++++++++++-------------- tests/test_writer.py | 53 +++++++++--------- 5 files changed, 151 insertions(+), 114 deletions(-) diff --git a/README.md b/README.md index e6de2bc..9ddf9d8 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,7 @@ Optional footers that are parsed by `changelog-gen` are: * `BREAKING CHANGE:` * `Refs: [#]` +* `Authors: (, ...)` The description is used to populate the changelog file. If the type includes the optional `!` flag, or the `BREAKING CHANGE` footer, this will lead to a diff --git a/changelog_gen/extractor.py b/changelog_gen/extractor.py index cdec1d7..b8c8118 100644 --- a/changelog_gen/extractor.py +++ b/changelog_gen/extractor.py @@ -1,5 +1,6 @@ from __future__ import annotations +import dataclasses import re import typing from collections import defaultdict @@ -9,7 +10,18 @@ from changelog_gen.vcs import Git from changelog_gen.version import BumpVersion -SectionDict = dict[str, dict[str, dict[str, str]]] + +@dataclasses.dataclass +class Change: # noqa: D101 + issue_ref: str + description: str + + authors: str = "" + scope: str = "" + breaking: bool = False + + +SectionDict = dict[str, dict[str, Change]] class ReleaseNoteExtractor: @@ -22,34 +34,38 @@ def __init__(self: typing.Self, supported_sections: list[str], *, dry_run: bool self.has_release_notes = self.release_notes.exists() and self.release_notes.is_dir() - def extract(self: typing.Self, section_mapping: dict[str, str] | None = None) -> SectionDict: - """Iterate over release note files extracting sections and issues.""" - section_mapping = section_mapping or {} - - sections = defaultdict(dict) - - if self.has_release_notes: - # Extract changelog details from release note files. - for issue in sorted(self.release_notes.iterdir()): - if issue.is_file and not issue.name.startswith("."): - issue_ref, section = issue.name.split(".") - section = section_mapping.get(section, section) - - breaking = False - if section.endswith("!"): - section = section[:-1] - breaking = True - - contents = issue.read_text().strip() - if section not in self.supported_sections: - msg = f"Unsupported CHANGELOG section {section}, derived from `./release_notes/{issue.name}`" - raise errors.InvalidSectionError(msg) - - sections[section][issue_ref] = { - "description": contents, - "breaking": breaking, - } + def _extract_release_notes( + self: typing.Self, + section_mapping: dict[str, str] | None, + sections: dict[str, dict], + ) -> None: + # Extract changelog details from release note files. + for issue in sorted(self.release_notes.iterdir()): + if issue.is_file and not issue.name.startswith("."): + issue_ref, section = issue.name.split(".") + section = section_mapping.get(section, section) + breaking = False + if section.endswith("!"): + section = section[:-1] + breaking = True + + contents = issue.read_text().strip() + if section not in self.supported_sections: + msg = f"Unsupported CHANGELOG section {section}, derived from `./release_notes/{issue.name}`" + raise errors.InvalidSectionError(msg) + + sections[section][issue_ref] = Change( + description=contents, + issue_ref=issue_ref, + breaking=breaking, + ) + + def _extract_commit_logs( + self: typing.Self, + section_mapping: dict[str, str] | None, + sections: dict[str, dict], + ) -> None: latest_info = Git.get_latest_tag_info() logs = Git.get_logs(latest_info["current_tag"]) @@ -62,7 +78,7 @@ def extract(self: typing.Self, section_mapping: dict[str, str] | None = None) -> m = reg.match(log) if m: section = m[1] - scope = m[2] + scope = m[2] or "" breaking = m[3] is not None message = m[4] details = m[5] or "" @@ -70,17 +86,37 @@ def extract(self: typing.Self, section_mapping: dict[str, str] | None = None) -> # Handle missing refs in commit message, skip link generation in writer issue_ref = f"__{i}__" breaking = breaking or "BREAKING CHANGE" in details + + change = Change( + description=message, + issue_ref=issue_ref, + breaking=breaking, + scope=scope, + ) + for line in details.split("\n"): - m = re.match(r"Refs: #?([\w-]+)", line) - if m: - issue_ref = m[1] + for target, pattern in [ + ("issue_ref", r"Refs: #?([\w-]+)"), + ("authors", r"Authors: (.*)"), + ]: + m = re.match(pattern, line) + if m: + setattr(change, target, m[1]) section = section_mapping.get(section, section) - sections[section][issue_ref] = { - "description": message, - "breaking": breaking, - "scope": scope, - } + sections[section][change.issue_ref] = change + + def extract(self: typing.Self, section_mapping: dict[str, str] | None = None) -> SectionDict: + """Iterate over release note files extracting sections and issues.""" + section_mapping = section_mapping or {} + + sections = defaultdict(dict) + + if self.has_release_notes: + self._extract_release_notes(section_mapping, sections) + + self._extract_commit_logs(section_mapping, sections) + return sections def unique_issues(self: typing.Self, sections: SectionDict) -> list[str]: @@ -120,7 +156,7 @@ def extract_version_tag(sections: SectionDict, semver_mapping: dict[str, str]) - if semvers.index(semver) < semvers.index(semver_mapping.get(section, "patch")): semver = semver_mapping.get(section, "patch") for issue in section_issues.values(): - if issue["breaking"]: + if issue.breaking: semver = "major" if current.startswith("0."): diff --git a/changelog_gen/writer.py b/changelog_gen/writer.py index f9489e9..05d54f3 100644 --- a/changelog_gen/writer.py +++ b/changelog_gen/writer.py @@ -54,17 +54,16 @@ def consume(self: typing.Self, supported_sections: dict[str, str], sections: Sec def add_section(self: typing.Self, header: str, lines: dict[str, dict]) -> None: """Add a section to changelog file.""" self._add_section_header(header) - for issue_ref, details in sorted(lines.items()): - description = details["description"] - scope = details.get("scope") - breaking = details.get("breaking", False) - - description = f"{scope} {description}" if scope else description - description = f"**Breaking:** {description}" if breaking else description + # TODO(edgy): sort based on change attributes + # https://github.com/EdgyEdgemond/changelog-gen/issues/75 + for _, change in sorted(lines.items()): + description = f"{change.scope} {change.description}" if change.scope else change.description + description = f"**Breaking:** {description}" if change.breaking else description + description = f"{description} {change.authors}" if change.authors else description self._add_section_line( description, - issue_ref, + change.issue_ref, ) self._post_section() diff --git a/tests/test_extractor.py b/tests/test_extractor.py index 766e3ba..6fcb799 100644 --- a/tests/test_extractor.py +++ b/tests/test_extractor.py @@ -4,7 +4,7 @@ from changelog_gen import errors, extractor from changelog_gen.config import SUPPORTED_SECTIONS -from changelog_gen.extractor import ReleaseNoteExtractor +from changelog_gen.extractor import Change, ReleaseNoteExtractor @pytest.fixture() @@ -89,12 +89,12 @@ def test_valid_notes_extraction(): assert sections == { "feat": { - "2": {"description": "Detail about 2", "breaking": False}, - "3": {"description": "Detail about 3", "breaking": False}, + "2": Change("2", "Detail about 2"), + "3": Change("3", "Detail about 3"), }, "fix": { - "1": {"description": "Detail about 1", "breaking": False}, - "4": {"description": "Detail about 4", "breaking": False}, + "1": Change("1", "Detail about 1"), + "4": Change("4", "Detail about 4"), }, } @@ -107,12 +107,12 @@ def test_breaking_notes_extraction(): assert sections == { "feat": { - "2": {"description": "Detail about 2", "breaking": False}, - "3": {"description": "Detail about 3", "breaking": True}, + "2": Change("2", "Detail about 2"), + "3": Change("3", "Detail about 3", breaking=True), }, "fix": { - "1": {"description": "Detail about 1", "breaking": True}, - "4": {"description": "Detail about 4", "breaking": False}, + "1": Change("1", "Detail about 1", breaking=True), + "4": Change("4", "Detail about 4"), }, } @@ -153,12 +153,12 @@ def test_git_commit_extraction(multiversion_repo): assert sections == { "feat": { - "2": {"description": "Detail about 2", "breaking": False, "scope": None}, - "3": {"description": "Detail about 3", "breaking": True, "scope": "(docs)"}, + "2": Change("2", "Detail about 2"), + "3": Change("3", "Detail about 3", breaking=True, scope="(docs)"), }, "fix": { - "1": {"description": "Detail about 1", "breaking": True, "scope": None}, - "4": {"description": "Detail about 4", "breaking": False, "scope": "(config)"}, + "1": Change("1", "Detail about 1", breaking=True), + "4": Change("4", "Detail about 4", scope="(config)"), }, } @@ -190,10 +190,10 @@ def test_git_commit_extraction_picks_up_custom_types(multiversion_repo): assert sections == { "feat": { - "2": {"description": "Detail about 2", "breaking": False, "scope": None}, + "2": Change("2", "Detail about 2"), }, "fix": { - "1": {"description": "Detail about 1", "breaking": True, "scope": None}, + "1": Change("1", "Detail about 1", breaking=True), }, } @@ -215,12 +215,12 @@ def test_section_remapping_can_remap_custom_sections(): sections = e.extract({"bug": "fix"}) assert sections == { "feat": { - "2": {"description": "Detail about 2", "breaking": False}, + "2": Change("2", "Detail about 2"), }, "fix": { - "1": {"description": "Detail about 1", "breaking": False}, - "3": {"description": "Detail about 3", "breaking": False}, - "4": {"description": "Detail about 4", "breaking": False}, + "1": Change("1", "Detail about 1"), + "3": Change("3", "Detail about 3"), + "4": Change("4", "Detail about 4"), }, } @@ -232,12 +232,12 @@ def test_section_mapping_can_handle_new_sections(): sections = e.extract({"fix": "bug"}) assert sections == { "feat": { - "2": {"description": "Detail about 2", "breaking": False}, + "2": Change("2", "Detail about 2"), }, "bug": { - "1": {"description": "Detail about 1", "breaking": False}, - "3": {"description": "Detail about 3", "breaking": False}, - "4": {"description": "Detail about 4", "breaking": False}, + "1": Change("1", "Detail about 1"), + "3": Change("3", "Detail about 3"), + "4": Change("4", "Detail about 4"), }, } @@ -247,15 +247,15 @@ def test_unique_issues(): assert e.unique_issues({ "unsupported": { - "5": {"description": "Detail about 4", "breaking": False}, + "5": Change("5", "Detail about 5"), }, "feat": { - "2": {"description": "Detail about 2", "breaking": False}, + "2": Change("2", "Detail about 2"), }, "bug": { - "2": {"description": "Detail about 2", "breaking": False}, - "3": {"description": "Detail about 3", "breaking": False}, - "4": {"description": "Detail about 4", "breaking": False}, + "2": Change("2", "Detail about 2"), + "3": Change("3", "Detail about 3"), + "4": Change("4", "Detail about 4"), }, }) == ["2", "3", "4"] @@ -290,13 +290,13 @@ def test_clean_removes_all_non_dotfiles(release_notes): @pytest.mark.parametrize( ("sections", "semver_mapping", "expected_semver"), [ - ({"fix": {"1": {"breaking": False}}}, {"feat": "minor"}, "patch"), - ({"feat": {"1": {"breaking": False}}}, {"feat": "minor"}, "patch"), - ({"fix": {"1": {"breaking": True}}}, {"feat": "minor"}, "minor"), - ({"feat": {"1": {"breaking": True}}}, {"feat": "minor"}, "minor"), - ({"custom": {"1": {"breaking": False}}}, {"custom": "patch"}, "patch"), - ({"custom": {"1": {"breaking": False}}}, {"custom": "minor"}, "patch"), - ({"custom": {"1": {"breaking": True}}}, {"custom": "minor"}, "minor"), + ({"fix": {"1": Change("1", "desc")}}, {"feat": "minor"}, "patch"), + ({"feat": {"1": Change("1", "desc")}}, {"feat": "minor"}, "patch"), + ({"fix": {"1": Change("1", "desc", breaking=True)}}, {"feat": "minor"}, "minor"), + ({"feat": {"1": Change("1", "desc", breaking=True)}}, {"feat": "minor"}, "minor"), + ({"custom": {"1": Change("1", "desc")}}, {"custom": "patch"}, "patch"), + ({"custom": {"1": Change("1", "desc")}}, {"custom": "minor"}, "patch"), + ({"custom": {"1": Change("1", "desc", breaking=True)}}, {"custom": "minor"}, "minor"), ], ) def test_extract_version_tag_version_zero(sections, semver_mapping, expected_semver, monkeypatch): @@ -314,13 +314,13 @@ def test_extract_version_tag_version_zero(sections, semver_mapping, expected_sem @pytest.mark.parametrize( ("sections", "semver_mapping", "expected_semver"), [ - ({"fix": {"1": {"breaking": False}}}, {"feat": "minor"}, "patch"), - ({"feat": {"1": {"breaking": False}}}, {"feat": "minor"}, "minor"), - ({"fix": {"1": {"breaking": True}}}, {"feat": "minor"}, "major"), - ({"feat": {"1": {"breaking": True}}}, {"feat": "minor"}, "major"), - ({"custom": {"1": {"breaking": False}}}, {"custom": "patch"}, "patch"), - ({"custom": {"1": {"breaking": False}}}, {"custom": "minor"}, "minor"), - ({"custom": {"1": {"breaking": True}}}, {"custom": "minor"}, "major"), + ({"fix": {"1": Change("1", "desc")}}, {"feat": "minor"}, "patch"), + ({"feat": {"1": Change("1", "desc")}}, {"feat": "minor"}, "minor"), + ({"fix": {"1": Change("1", "desc", breaking=True)}}, {"feat": "minor"}, "major"), + ({"feat": {"1": Change("1", "desc", breaking=True)}}, {"feat": "minor"}, "major"), + ({"custom": {"1": Change("1", "desc")}}, {"custom": "patch"}, "patch"), + ({"custom": {"1": Change("1", "desc")}}, {"custom": "minor"}, "minor"), + ({"custom": {"1": Change("1", "desc", breaking=True)}}, {"custom": "minor"}, "major"), ], ) def test_extract_version_tag(sections, semver_mapping, expected_semver, monkeypatch): diff --git a/tests/test_writer.py b/tests/test_writer.py index ecc6a2a..5389624 100644 --- a/tests/test_writer.py +++ b/tests/test_writer.py @@ -3,6 +3,7 @@ import pytest from changelog_gen import writer +from changelog_gen.extractor import Change @pytest.mark.parametrize( @@ -120,16 +121,16 @@ def test_add_section(self, monkeypatch, changelog): w.add_section( "header", { - "1": {"description": "line1"}, - "2": {"description": "line2"}, - "3": {"description": "line3", "scope": "(config)"}, + "1": Change("1", "line1", breaking=True), + "2": Change("2", "line2", authors="(a, b)"), + "3": Change("3", "line3", scope="(config)"), }, ) assert w._add_section_header.call_args == mock.call("header") assert w._add_section_line.call_args_list == [ - mock.call("line1", "1"), - mock.call("line2", "2"), + mock.call("**Breaking:** line1", "1"), + mock.call("line2 (a, b)", "2"), mock.call("(config) line3", "3"), ] @@ -227,9 +228,9 @@ def test_write_dry_run_doesnt_write_to_file(self, changelog_md): w.add_section( "header", { - "1": {"description": "line1"}, - "2": {"description": "line2"}, - "3": {"description": "line3", "scope": "(config)"}, + "1": Change("1", "line1"), + "2": Change("2", "line2"), + "3": Change("3", "line3", scope="(config)"), }, ) @@ -242,9 +243,9 @@ def test_write(self, changelog_md): w.add_section( "header", { - "1": {"description": "line1"}, - "2": {"description": "line2"}, - "3": {"description": "line3", "scope": "(config)"}, + "1": Change("1", "line1"), + "2": Change("2", "line2"), + "3": Change("3", "line3", scope="(config)"), }, ) @@ -282,9 +283,9 @@ def test_write_with_existing_content(self, changelog_md): w.add_section( "header", { - "4": {"description": "line4"}, - "5": {"description": "line5"}, - "6": {"description": "line6", "scope": "(config)"}, + "4": Change("4", "line4"), + "5": Change("5", "line5"), + "6": Change("6", "line6", scope="(config)"), }, ) @@ -420,9 +421,9 @@ def test_str_with_links(self, changelog_rst): w.add_section( "header", { - "1": {"description": "line1"}, - "2": {"description": "line2"}, - "3": {"description": "line3", "scope": "(config)"}, + "1": Change("1", "line1"), + "2": Change("2", "line2"), + "3": Change("3", "line3", scope="(config)"), }, ) @@ -453,9 +454,9 @@ def test_write_dry_run_doesnt_write_to_file(self, changelog_rst): w.add_section( "header", { - "1": {"description": "line1"}, - "2": {"description": "line2"}, - "3": {"description": "line3", "scope": "(config)"}, + "1": Change("1", "line1"), + "2": Change("2", "line2"), + "3": Change("3", "line3", scope="(config)"), }, ) @@ -474,9 +475,9 @@ def test_write(self, changelog_rst): w.add_section( "header", { - "1": {"description": "line1"}, - "2": {"description": "line2"}, - "3": {"description": "line3", "scope": "(config)"}, + "1": Change("1", "line1"), + "2": Change("2", "line2"), + "3": Change("3", "line3", scope="(config)"), }, ) @@ -526,9 +527,9 @@ def test_write_with_existing_content(self, changelog_rst): w.add_section( "header", { - "4": {"description": "line4"}, - "5": {"description": "line5"}, - "6": {"description": "line6", "scope": "(config)"}, + "4": Change("4", "line4"), + "5": Change("5", "line5"), + "6": Change("6", "line6", scope="(config)"), }, )