Skip to content
This repository has been archived by the owner on Mar 8, 2024. It is now read-only.

Commit

Permalink
feat(extractor): Extract authors footer from commit logs (#97)
Browse files Browse the repository at this point in the history
Convert extracted dict into a dataclass and capture additional Authors
footer

Refs: #76
Authors: (edgy)
  • Loading branch information
EdgyEdgemond authored Mar 2, 2024
1 parent bee2c5f commit eed0a04
Show file tree
Hide file tree
Showing 5 changed files with 151 additions and 114 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ Optional footers that are parsed by `changelog-gen` are:

* `BREAKING CHANGE:`
* `Refs: [#]<issue_ref>`
* `Authors: (<author>, ...)`

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
Expand Down
112 changes: 74 additions & 38 deletions changelog_gen/extractor.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import dataclasses
import re
import typing
from collections import defaultdict
Expand All @@ -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:
Expand All @@ -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"])

Expand All @@ -62,25 +78,45 @@ 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 ""

# 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]:
Expand Down Expand Up @@ -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."):
Expand Down
15 changes: 7 additions & 8 deletions changelog_gen/writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
84 changes: 42 additions & 42 deletions tests/test_extractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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"),
},
}

Expand All @@ -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"),
},
}

Expand Down Expand Up @@ -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)"),
},
}

Expand Down Expand Up @@ -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),
},
}

Expand All @@ -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"),
},
}

Expand All @@ -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"),
},
}

Expand All @@ -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"]

Expand Down Expand Up @@ -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):
Expand All @@ -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):
Expand Down
Loading

0 comments on commit eed0a04

Please sign in to comment.