From 1ff8d8757ccc2533f41f210967786a2d636c5bb3 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Mon, 11 Nov 2024 15:55:16 -0700 Subject: [PATCH 01/14] refactor(parsers): add parser option validation to commit parsing --- src/semantic_release/commit_parser/angular.py | 39 +++++++++++---- src/semantic_release/commit_parser/emoji.py | 49 ++++++++++++------- src/semantic_release/errors.py | 4 ++ 3 files changed, 66 insertions(+), 26 deletions(-) diff --git a/src/semantic_release/commit_parser/angular.py b/src/semantic_release/commit_parser/angular.py index 4776dc3e2..7bac17e8e 100644 --- a/src/semantic_release/commit_parser/angular.py +++ b/src/semantic_release/commit_parser/angular.py @@ -23,6 +23,7 @@ ) from semantic_release.commit_parser.util import breaking_re, parse_paragraphs from semantic_release.enums import LevelBump +from semantic_release.errors import InvalidParserOptions if TYPE_CHECKING: # pragma: no cover from git.objects.commit import Commit @@ -72,16 +73,19 @@ class AngularParserOptions(ParserOptions): default_bump_level: LevelBump = LevelBump.NO_RELEASE def __post_init__(self) -> None: - self.tag_to_level: dict[str, LevelBump] = dict( - [ + self.tag_to_level: dict[str, LevelBump] = { + str(tag): level + for tag, level in [ # we have to do a type ignore as zip_longest provides a type that is not specific enough # for our expected output. Due to the empty second array, we know the first is always longest - # and that means no values in the first entry of the tuples will ever be a LevelBump. - *zip_longest(self.allowed_tags, (), fillvalue=self.default_bump_level), # type: ignore[list-item] - *zip_longest(self.patch_tags, (), fillvalue=LevelBump.PATCH), # type: ignore[list-item] - *zip_longest(self.minor_tags, (), fillvalue=LevelBump.MINOR), # type: ignore[list-item] + # and that means no values in the first entry of the tuples will ever be a LevelBump. We + # apply a str() to make mypy happy although it will never happen. + *zip_longest(self.allowed_tags, (), fillvalue=self.default_bump_level), + *zip_longest(self.patch_tags, (), fillvalue=LevelBump.PATCH), + *zip_longest(self.minor_tags, (), fillvalue=LevelBump.MINOR), ] - ) + if "|" not in str(tag) + } class AngularCommitParser(CommitParser[ParseResult, AngularParserOptions]): @@ -95,12 +99,28 @@ class AngularCommitParser(CommitParser[ParseResult, AngularParserOptions]): def __init__(self, options: AngularParserOptions | None = None) -> None: super().__init__(options) - all_possible_types = str.join("|", self.options.allowed_tags) + + try: + commit_type_pattern = regexp( + r"(?P%s)" % str.join("|", self.options.allowed_tags) + ) + except re.error as err: + raise InvalidParserOptions( + str.join( + "\n", + [ + f"Invalid options for {self.__class__.__name__}", + "Unable to create regular expression from configured commit-types.", + "Please check the configured commit-types and remove or escape any regular expression characters.", + ], + ) + ) from err + self.re_parser = regexp( str.join( "", [ - r"(?P%s)" % all_possible_types, + r"^" + commit_type_pattern.pattern, r"(?:\((?P[^\n]+)\))?", # TODO: remove ! support as it is not part of the angular commit spec (its part of conventional commits spec) r"(?P!)?:\s+", @@ -110,6 +130,7 @@ def __init__(self, options: AngularParserOptions | None = None) -> None: ), flags=re.DOTALL, ) + # GitHub & Gitea use (#123), GitLab uses (!123), and BitBucket uses (pull request #123) self.mr_selector = regexp( r"[\t ]+\((?:pull request )?(?P[#!]\d+)\)[\t ]*$" diff --git a/src/semantic_release/commit_parser/emoji.py b/src/semantic_release/commit_parser/emoji.py index fee4ab6c5..206c8bcf8 100644 --- a/src/semantic_release/commit_parser/emoji.py +++ b/src/semantic_release/commit_parser/emoji.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import re from itertools import zip_longest from re import compile as regexp from typing import Tuple @@ -18,6 +19,7 @@ ) from semantic_release.commit_parser.util import parse_paragraphs from semantic_release.enums import LevelBump +from semantic_release.errors import InvalidParserOptions logger = logging.getLogger(__name__) @@ -57,17 +59,20 @@ class EmojiParserOptions(ParserOptions): default_bump_level: LevelBump = LevelBump.NO_RELEASE def __post_init__(self) -> None: - self.tag_to_level: dict[str, LevelBump] = dict( - [ + self.tag_to_level: dict[str, LevelBump] = { + str(tag): level + for tag, level in [ # we have to do a type ignore as zip_longest provides a type that is not specific enough # for our expected output. Due to the empty second array, we know the first is always longest - # and that means no values in the first entry of the tuples will ever be a LevelBump. - *zip_longest(self.allowed_tags, (), fillvalue=self.default_bump_level), # type: ignore[list-item] - *zip_longest(self.patch_tags, (), fillvalue=LevelBump.PATCH), # type: ignore[list-item] - *zip_longest(self.minor_tags, (), fillvalue=LevelBump.MINOR), # type: ignore[list-item] - *zip_longest(self.major_tags, (), fillvalue=LevelBump.MAJOR), # type: ignore[list-item] + # and that means no values in the first entry of the tuples will ever be a LevelBump. We + # apply a str() to make mypy happy although it will never happen. + *zip_longest(self.allowed_tags, (), fillvalue=self.default_bump_level), + *zip_longest(self.patch_tags, (), fillvalue=LevelBump.PATCH), + *zip_longest(self.minor_tags, (), fillvalue=LevelBump.MINOR), + *zip_longest(self.major_tags, (), fillvalue=LevelBump.MAJOR), ] - ) + if "|" not in str(tag) + } class EmojiCommitParser(CommitParser[ParseResult, EmojiParserOptions]): @@ -88,15 +93,25 @@ class EmojiCommitParser(CommitParser[ParseResult, EmojiParserOptions]): def __init__(self, options: EmojiParserOptions | None = None) -> None: super().__init__(options) - prcedence_order_regex = str.join( - "|", - [ - *self.options.major_tags, - *self.options.minor_tags, - *self.options.patch_tags, - ], - ) - self.emoji_selector = regexp(r"(?P%s)" % prcedence_order_regex) + + # Reverse the list of tags to ensure that the highest level tags are matched first + emojis_in_precedence_order = list(self.options.tag_to_level.keys())[::-1] + + try: + self.emoji_selector = regexp( + r"(?P%s)" % str.join("|", emojis_in_precedence_order) + ) + except re.error as err: + raise InvalidParserOptions( + str.join( + "\n", + [ + f"Invalid options for {self.__class__.__name__}", + "Unable to create regular expression from configured commit-types.", + "Please check the configured commit-types and remove or escape any regular expression characters.", + ], + ) + ) from err # GitHub & Gitea use (#123), GitLab uses (!123), and BitBucket uses (pull request #123) self.mr_selector = regexp( diff --git a/src/semantic_release/errors.py b/src/semantic_release/errors.py index ae7f7deed..954e85d20 100644 --- a/src/semantic_release/errors.py +++ b/src/semantic_release/errors.py @@ -16,6 +16,10 @@ class InvalidConfiguration(SemanticReleaseBaseError): """Raised when configuration is deemed invalid""" +class InvalidParserOptions(InvalidConfiguration): + """Raised when the parser options are invalid""" + + class MissingGitRemote(SemanticReleaseBaseError): """Raised when repository is missing the configured remote origin or upstream""" From 89cc2c0c1a51011cfe44d840edc0dbf11ca114c2 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Mon, 11 Nov 2024 16:05:30 -0700 Subject: [PATCH 02/14] docs(api-parsers): add option documentation to parser options --- src/semantic_release/commit_parser/angular.py | 19 ++++++++++++++++++- src/semantic_release/commit_parser/emoji.py | 18 +++++++++++++++++- src/semantic_release/commit_parser/scipy.py | 16 +++++++++++++++- 3 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/semantic_release/commit_parser/angular.py b/src/semantic_release/commit_parser/angular.py index 7bac17e8e..141f593c7 100644 --- a/src/semantic_release/commit_parser/angular.py +++ b/src/semantic_release/commit_parser/angular.py @@ -58,7 +58,11 @@ class AngularParserOptions(ParserOptions): """Options dataclass for AngularCommitParser""" minor_tags: Tuple[str, ...] = ("feat",) + """Commit-type prefixes that should result in a minor release bump.""" + patch_tags: Tuple[str, ...] = ("fix", "perf") + """Commit-type prefixes that should result in a patch release bump.""" + allowed_tags: Tuple[str, ...] = ( *minor_tags, *patch_tags, @@ -70,10 +74,23 @@ class AngularParserOptions(ParserOptions): "refactor", "test", ) + """ + All commit-type prefixes that are allowed. + + These are used to identify a valid commit message. If a commit message does not start with + one of these prefixes, it will not be considered a valid commit message. + """ + default_bump_level: LevelBump = LevelBump.NO_RELEASE + """The minimum bump level to apply to valid commit message.""" + + @property + def tag_to_level(self) -> dict[str, LevelBump]: + """A mapping of commit tags to the level bump they should result in.""" + return self._tag_to_level def __post_init__(self) -> None: - self.tag_to_level: dict[str, LevelBump] = { + self._tag_to_level: dict[str, LevelBump] = { str(tag): level for tag, level in [ # we have to do a type ignore as zip_longest provides a type that is not specific enough diff --git a/src/semantic_release/commit_parser/emoji.py b/src/semantic_release/commit_parser/emoji.py index 206c8bcf8..d3eeb895c 100644 --- a/src/semantic_release/commit_parser/emoji.py +++ b/src/semantic_release/commit_parser/emoji.py @@ -26,7 +26,11 @@ @dataclass class EmojiParserOptions(ParserOptions): + """Options dataclass for EmojiCommitParser""" + major_tags: Tuple[str, ...] = (":boom:",) + """Commit-type prefixes that should result in a major release bump.""" + minor_tags: Tuple[str, ...] = ( ":sparkles:", ":children_crossing:", @@ -35,6 +39,8 @@ class EmojiParserOptions(ParserOptions): ":egg:", ":chart_with_upwards_trend:", ) + """Commit-type prefixes that should result in a minor release bump.""" + patch_tags: Tuple[str, ...] = ( ":ambulance:", ":lock:", @@ -51,15 +57,25 @@ class EmojiParserOptions(ParserOptions): ":robot:", ":green_apple:", ) + """Commit-type prefixes that should result in a patch release bump.""" + allowed_tags: Tuple[str, ...] = ( *major_tags, *minor_tags, *patch_tags, ) + """All commit-type prefixes that are allowed.""" + default_bump_level: LevelBump = LevelBump.NO_RELEASE + """The minimum bump level to apply to valid commit message.""" + + @property + def tag_to_level(self) -> dict[str, LevelBump]: + """A mapping of commit tags to the level bump they should result in.""" + return self._tag_to_level def __post_init__(self) -> None: - self.tag_to_level: dict[str, LevelBump] = { + self._tag_to_level: dict[str, LevelBump] = { str(tag): level for tag, level in [ # we have to do a type ignore as zip_longest provides a type that is not specific enough diff --git a/src/semantic_release/commit_parser/scipy.py b/src/semantic_release/commit_parser/scipy.py index 4ee309c0a..5dd82eead 100644 --- a/src/semantic_release/commit_parser/scipy.py +++ b/src/semantic_release/commit_parser/scipy.py @@ -102,8 +102,14 @@ class ScipyParserOptions(AngularParserOptions): """ major_tags: Tuple[str, ...] = ("API",) + """Commit-type prefixes that should result in a major release bump.""" + minor_tags: Tuple[str, ...] = ("DEP", "DEV", "ENH", "REV", "FEAT") + """Commit-type prefixes that should result in a minor release bump.""" + patch_tags: Tuple[str, ...] = ("BLD", "BUG", "MAINT") + """Commit-type prefixes that should result in a patch release bump.""" + allowed_tags: Tuple[str, ...] = ( *major_tags, *minor_tags, @@ -115,15 +121,23 @@ class ScipyParserOptions(AngularParserOptions): "REL", "TEST", ) + """ + All commit-type prefixes that are allowed. + + These are used to identify a valid commit message. If a commit message does not start with + one of these prefixes, it will not be considered a valid commit message. + """ + # TODO: breaking v10, make consistent with AngularParserOptions default_level_bump: LevelBump = LevelBump.NO_RELEASE + """The minimum bump level to apply to valid commit message.""" def __post_init__(self) -> None: # TODO: breaking v10, remove as the name is now consistent self.default_bump_level = self.default_level_bump super().__post_init__() for tag in self.major_tags: - self.tag_to_level[tag] = LevelBump.MAJOR + self._tag_to_level[tag] = LevelBump.MAJOR class ScipyCommitParser(AngularCommitParser): From 1ff760cf77a6008af355b52ab10a6aa0813ace85 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Mon, 11 Nov 2024 16:06:32 -0700 Subject: [PATCH 03/14] feat(parsers): add `other_allowed_tags` option for commit parser options --- src/semantic_release/commit_parser/angular.py | 11 ++++++++--- src/semantic_release/commit_parser/emoji.py | 4 ++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/semantic_release/commit_parser/angular.py b/src/semantic_release/commit_parser/angular.py index 141f593c7..4cc1e82d1 100644 --- a/src/semantic_release/commit_parser/angular.py +++ b/src/semantic_release/commit_parser/angular.py @@ -63,9 +63,7 @@ class AngularParserOptions(ParserOptions): patch_tags: Tuple[str, ...] = ("fix", "perf") """Commit-type prefixes that should result in a patch release bump.""" - allowed_tags: Tuple[str, ...] = ( - *minor_tags, - *patch_tags, + other_allowed_tags: Tuple[str, ...] = ( "build", "chore", "ci", @@ -74,6 +72,13 @@ class AngularParserOptions(ParserOptions): "refactor", "test", ) + """Commit-type prefixes that are allowed but do not result in a version bump.""" + + allowed_tags: Tuple[str, ...] = ( + *minor_tags, + *patch_tags, + *other_allowed_tags, + ) """ All commit-type prefixes that are allowed. diff --git a/src/semantic_release/commit_parser/emoji.py b/src/semantic_release/commit_parser/emoji.py index d3eeb895c..798be78d6 100644 --- a/src/semantic_release/commit_parser/emoji.py +++ b/src/semantic_release/commit_parser/emoji.py @@ -59,10 +59,14 @@ class EmojiParserOptions(ParserOptions): ) """Commit-type prefixes that should result in a patch release bump.""" + other_allowed_tags: Tuple[str, ...] = () + """Commit-type prefixes that are allowed but do not result in a version bump.""" + allowed_tags: Tuple[str, ...] = ( *major_tags, *minor_tags, *patch_tags, + *other_allowed_tags, ) """All commit-type prefixes that are allowed.""" From b2b151538460557559fd4f09e6fec5fabef15d7a Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Wed, 23 Oct 2024 23:15:31 -0600 Subject: [PATCH 04/14] feat(commit-parser): enable custom parsers to identify linked issues on a commit --- src/semantic_release/commit_parser/token.py | 109 +++++++++++++++++++- 1 file changed, 107 insertions(+), 2 deletions(-) diff --git a/src/semantic_release/commit_parser/token.py b/src/semantic_release/commit_parser/token.py index db23a1fba..021b43dc2 100644 --- a/src/semantic_release/commit_parser/token.py +++ b/src/semantic_release/commit_parser/token.py @@ -11,48 +11,132 @@ class ParsedMessageResult(NamedTuple): + """ + A read-only named tuple object representing the result from parsing a commit message. + + Essentially this is a data structure which holds the parsed information from a commit message + without the actual commit object itself. Very helpful for unit testing. + + Most of the fields will replicate the fields of a + :py:class:`ParsedCommit ` + """ + bump: LevelBump type: str category: str scope: str descriptions: tuple[str, ...] breaking_descriptions: tuple[str, ...] = () + linked_issues: tuple[str, ...] = () linked_merge_request: str = "" include_in_changelog: bool = True class ParsedCommit(NamedTuple): + """A read-only named tuple object representing the result of parsing a commit message.""" + bump: LevelBump + """A LevelBump enum value indicating what type of change this commit introduces.""" + type: str + """ + The type of the commit as a string, per the commit message style. + + This is up to the parser to implement; for example, the EmojiCommitParser + parser fills this field with the emoji representing the most significant change for + the commit. + """ + scope: str + """ + The scope, as a string, parsed from the commit. + + Generally an optional field based on the commit message style which means it very likely can be an empty string. + Commit styles which do not have a meaningful concept of "scope" usually fill this field with an empty string. + """ + descriptions: list[str] + """ + A list of paragraphs from the commit message. + + Paragraphs are generally delimited by a double-newline since git commit messages are sometimes manually wordwrapped with + a single newline, but this is up to the parser to implement. + """ + breaking_descriptions: list[str] + """ + A list of paragraphs which are deemed to identify and describe breaking changes by the parser. + + An example would be a paragraph which begins with the text ``BREAKING CHANGE:`` in the commit message but + the parser gennerally strips the prefix and includes the rest of the paragraph in this list. + """ + commit: Commit + """The original commit object (a class defined by GitPython) that was parsed""" + + linked_issues: tuple[str, ...] = () + """ + A tuple of issue numbers as strings, if the commit is contains issue references. + + If there are no issue references, this should be an empty tuple. Although, we generally + refer to them as "issue numbers", it generally should be a string to adhere to the prefixes + used by the VCS (ex. ``#`` for GitHub, GitLab, etc.) or issue tracker (ex. JIRA uses ``AAA-###``). + """ + linked_merge_request: str = "" + """ + A pull request or merge request definition, if the commit is labeled with a pull/merge request number. + + This is a string value which includes any special character prefix used by the VCS + (e.g. ``#`` for GitHub, ``!`` for GitLab). + """ + include_in_changelog: bool = True + """ + A boolean value indicating whether this commit should be included in the changelog. + + This enables parsers to flag commits which are not user-facing or are otherwise not + relevant to the changelog to be filtered out by PSR's internal algorithms. + """ @property def message(self) -> str: + """ + A string representation of the commit message. + + This is a pass through property for convience to access the ``message`` + attribute of the ``commit`` object. + + If the message is of type ``bytes`` then it will be decoded to a ``UTF-8`` string. + """ m = self.commit.message message_str = m.decode("utf-8") if isinstance(m, bytes) else m return message_str.replace("\r", "") @property def hexsha(self) -> str: + """ + A hex representation of the hash value of the commit. + + This is a pass through property for convience to access the ``hexsha`` attribute of the ``commit``. + """ return self.commit.hexsha @property def short_hash(self) -> str: - return self.commit.hexsha[:7] + """A short representation of the hash value (in hex) of the commit.""" + return self.hexsha[:7] @property def linked_pull_request(self) -> str: + """An alias to the linked_merge_request attribute.""" return self.linked_merge_request @staticmethod def from_parsed_message_result( commit: Commit, parsed_message_result: ParsedMessageResult ) -> ParsedCommit: + """A convience method to create a ParsedCommit object from a ParsedMessageResult object and a Commit object.""" return ParsedCommit( bump=parsed_message_result.bump, # TODO: breaking v10, swap back to type rather than category @@ -61,30 +145,51 @@ def from_parsed_message_result( descriptions=list(parsed_message_result.descriptions), breaking_descriptions=list(parsed_message_result.breaking_descriptions), commit=commit, + linked_issues=parsed_message_result.linked_issues, linked_merge_request=parsed_message_result.linked_merge_request, include_in_changelog=parsed_message_result.include_in_changelog, ) class ParseError(NamedTuple): + """A read-only named tuple object representing an error that occurred while parsing a commit message.""" + commit: Commit + """The original commit object (a class defined by GitPython) that was parsed""" + error: str + """A string with a description for why the commit parsing failed.""" @property def message(self) -> str: + """ + A string representation of the commit message. + + This is a pass through property for convience to access the ``message`` + attribute of the ``commit`` object. + + If the message is of type ``bytes`` then it will be decoded to a ``UTF-8`` string. + """ m = self.commit.message message_str = m.decode("utf-8") if isinstance(m, bytes) else m return message_str.replace("\r", "") @property def hexsha(self) -> str: + """ + A hex representation of the hash value of the commit. + + This is a pass through property for convience to access the ``hexsha`` attribute of the ``commit``. + """ return self.commit.hexsha @property def short_hash(self) -> str: - return self.commit.hexsha[:7] + """A short representation of the hash value (in hex) of the commit.""" + return self.hexsha[:7] def raise_error(self) -> NoReturn: + """A convience method to raise a CommitParseError with the error message.""" raise CommitParseError(self.error) From 88c9ca44228947dc0959f833a394fe9f8a06f903 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sat, 30 Nov 2024 23:29:08 -0700 Subject: [PATCH 05/14] test(parser-angular): add unit tests to verify parsing of issue numbers --- tests/const.py | 19 ++ .../commit_parser/test_angular.py | 280 +++++++++++++++++- 2 files changed, 297 insertions(+), 2 deletions(-) diff --git a/tests/const.py b/tests/const.py index 8cd302fba..e9da2d43d 100644 --- a/tests/const.py +++ b/tests/const.py @@ -44,6 +44,25 @@ class RepoActionStep(str, Enum): COMMIT_MESSAGE = "{version}\n\nAutomatically generated by python-semantic-release\n" +SUPPORTED_ISSUE_CLOSURE_PREFIXES = [ + "Close", + "Closes", + "Closed", + "Closing", + "Fix", + "Fixes", + "Fixed", + "Fixing", + "Resolve", + "Resolves", + "Resolved", + "Resolving", + "Implement", + "Implements", + "Implemented", + "Implementing", +] + ANGULAR_COMMITS_CHORE = ("ci: added a commit lint job\n",) # Different in-scope commits that produce a certain release type ANGULAR_COMMITS_PATCH = ( diff --git a/tests/unit/semantic_release/commit_parser/test_angular.py b/tests/unit/semantic_release/commit_parser/test_angular.py index f85dd013a..5bb2250e9 100644 --- a/tests/unit/semantic_release/commit_parser/test_angular.py +++ b/tests/unit/semantic_release/commit_parser/test_angular.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Sequence import pytest @@ -11,6 +11,8 @@ from semantic_release.commit_parser.token import ParsedCommit, ParseError from semantic_release.enums import LevelBump +from tests.const import SUPPORTED_ISSUE_CLOSURE_PREFIXES + if TYPE_CHECKING: from tests.conftest import MakeCommitObjFn @@ -112,7 +114,7 @@ def test_parser_return_scope_from_commit_message( "This is an long explanatory part of a commit message. It should give " "some insight to the fix this commit adds to the codebase." ) -_footer = "Closes #400" +_footer = "Closes: #400" @pytest.mark.parametrize( @@ -185,6 +187,280 @@ def test_parser_return_linked_merge_request_from_commit_message( assert subject == result.descriptions[0] +@pytest.mark.parametrize( + "message, linked_issues", + # TODO: in v10, we will remove the issue reference footers from the descriptions + [ + *[ + # GitHub, Gitea, GitLab style + ( + f"feat(parser): add magic parser\n\n{footer}", + linked_issues, + ) + for footer_prefix in SUPPORTED_ISSUE_CLOSURE_PREFIXES + for footer, linked_issues in [ + # Single issue + ( + f"{footer_prefix.capitalize()}: #555", + ["#555"], + ), # Git Footer style (capitalized) + (f"{footer_prefix.lower()}: #555", ["#555"]), # lowercase prefix + (f"{footer_prefix.upper()}: #555", ["#555"]), # uppercase prefix + # Mulitple issues (variant 1: list with one prefix, not supported by GitHub) + ( + f"{footer_prefix}: #555,#444", + ["#444", "#555"], + ), # Comma separated (w/o space) + ( + f"{footer_prefix}: #555, #444", + ["#444", "#555"], + ), # Comma separated (w/ space) + ( + f"{footer_prefix}: #555 , #444", + ["#444", "#555"], + ), # Comma separated (w/ extra space) + (f"{footer_prefix}: #555 #444", ["#444", "#555"]), # Space separated + ( + f"{footer_prefix}: #555;#444", + ["#444", "#555"], + ), # semicolon separated (w/o space) + ( + f"{footer_prefix}: #555; #444", + ["#444", "#555"], + ), # semicolon separated (w/ space) + ( + f"{footer_prefix}: #555 ; #444", + ["#444", "#555"], + ), # semicolon separated (w/ extra space) + ( + f"{footer_prefix}: #555/#444", + ["#444", "#555"], + ), # slash separated (w/o space) + ( + f"{footer_prefix}: #555/ #444", + ["#444", "#555"], + ), # slash separated (w/ space) + ( + f"{footer_prefix}: #555 / #444", + ["#444", "#555"], + ), # slash separated (w/ extra space) + ( + f"{footer_prefix}: #555Ƽ", + ["#444", "#555"], + ), # ampersand separated (w/o space) + ( + f"{footer_prefix}: #555& #444", + ["#444", "#555"], + ), # ampersand separated (w/ space) + ( + f"{footer_prefix}: #555 & #444", + ["#444", "#555"], + ), # ampersand separated (w/ extra space) + (f"{footer_prefix}: #555 and #444", ["#444", "#555"]), # and separated + ( + f"{footer_prefix}: #555, #444, and #333", + ["#333", "#444", "#555"], + ), # and separated + # Mulitple issues (variant 2: multiple footers, supported by GitHub) + (f"{footer_prefix}: #555\n{footer_prefix}: #444", ["#444", "#555"]), + # More than 2 issues + ( + f"{footer_prefix}: #555, #444, #333", + ["#333", "#444", "#555"], + ), # TODO: force ordering? + # Single issue listed multiple times + (f"{footer_prefix}: #555, #555", ["#555"]), + # Multiple footers with the same issue + (f"{footer_prefix}: #555\n{footer_prefix}: #555", ["#555"]), + # Multiple issues via multiple inline git footers + (f"{footer_prefix}: #555, {footer_prefix}: #444", ["#444", "#555"]), + # Multiple valid footers + ( + str.join( + "\n", + [ + f"{footer_prefix}: #555", + "Signed-off-by: johndoe ", + f"{footer_prefix}: #444", + ], + ), + ["#444", "#555"], + ), + # ----------------------------------------- Invalid Sets ----------------------------------------- # + # Must have colon because it is a git footer, these will not return a linked issue + (f"{footer_prefix} #666", []), + (f"{footer_prefix} #666, #777", []), + # Invalid Multiple issues (although it is supported by GitHub, it is not supported by the parser) + (f"{footer_prefix} #666, {footer_prefix} #777", []), + # Invalid 'and' separation + (f"{footer_prefix}: #666and#777", ["#666and#777"]), + # Invalid prefix + ("ref: #666", []), + # body mentions an issue and has a different git footer + ( + "In #666, the devils in the details...\n\nSigned-off-by: johndoe ", + [], + ), + ] + ], + *[ + # JIRA style + ( + f"feat(parser): add magic parser\n\n{footer}", + linked_issues, + ) + for footer_prefix in SUPPORTED_ISSUE_CLOSURE_PREFIXES + for footer, linked_issues in [ + # Single issue + ( + f"{footer_prefix.capitalize()}: ABC-555", + ["ABC-555"], + ), # Git Footer style (capitalized) + (f"{footer_prefix.lower()}: ABC-555", ["ABC-555"]), # lowercase prefix + (f"{footer_prefix.upper()}: ABC-555", ["ABC-555"]), # uppercase prefix + # Mulitple issues (variant 1: list with one prefix, not supported by GitHub) + ( + f"{footer_prefix}: ABC-555,ABC-444", + ["ABC-444", "ABC-555"], + ), # Comma separated (w/o space) + ( + f"{footer_prefix}: ABC-555, ABC-444", + ["ABC-444", "ABC-555"], + ), # Comma separated (w/ space) + ( + f"{footer_prefix}: ABC-555 , ABC-444", + ["ABC-444", "ABC-555"], + ), # Comma separated (w/ extra space) + ( + f"{footer_prefix}: ABC-555 ABC-444", + ["ABC-444", "ABC-555"], + ), # Space separated + ( + f"{footer_prefix}: ABC-555;ABC-444", + ["ABC-444", "ABC-555"], + ), # semicolon separated (w/o space) + ( + f"{footer_prefix}: ABC-555; ABC-444", + ["ABC-444", "ABC-555"], + ), # semicolon separated (w/ space) + ( + f"{footer_prefix}: ABC-555 ; ABC-444", + ["ABC-444", "ABC-555"], + ), # semicolon separated (w/ extra space) + ( + f"{footer_prefix}: ABC-555/ABC-444", + ["ABC-444", "ABC-555"], + ), # slash separated (w/o space) + ( + f"{footer_prefix}: ABC-555/ ABC-444", + ["ABC-444", "ABC-555"], + ), # slash separated (w/ space) + ( + f"{footer_prefix}: ABC-555 / ABC-444", + ["ABC-444", "ABC-555"], + ), # slash separated (w/ extra space) + ( + f"{footer_prefix}: ABC-555&ABC-444", + ["ABC-444", "ABC-555"], + ), # ampersand separated (w/o space) + ( + f"{footer_prefix}: ABC-555& ABC-444", + ["ABC-444", "ABC-555"], + ), # ampersand separated (w/ space) + ( + f"{footer_prefix}: ABC-555 & ABC-444", + ["ABC-444", "ABC-555"], + ), # ampersand separated (w/ extra space) + ( + f"{footer_prefix}: ABC-555 and ABC-444", + ["ABC-444", "ABC-555"], + ), # and separated + ( + f"{footer_prefix}: ABC-555, ABC-444, and ABC-333", + ["ABC-333", "ABC-444", "ABC-555"], + ), # and separated + # Mulitple issues (variant 2: multiple footers, supported by GitHub) + ( + f"{footer_prefix}: ABC-555\n{footer_prefix}: ABC-444", + ["ABC-444", "ABC-555"], + ), + # More than 2 issues + ( + f"{footer_prefix}: ABC-555, ABC-444, ABC-333", + ["ABC-333", "ABC-444", "ABC-555"], + ), # TODO: force ordering? + # Single issue listed multiple times + (f"{footer_prefix}: ABC-555, ABC-555", ["ABC-555"]), + # Multiple footers with the same issue + (f"{footer_prefix}: ABC-555\n{footer_prefix}: ABC-555", ["ABC-555"]), + # Multiple issues via multiple inline git footers + ( + f"{footer_prefix}: ABC-666, {footer_prefix}: ABC-777", + ["ABC-666", "ABC-777"], + ), + # Multiple valid footers + ( + str.join( + "\n", + [ + f"{footer_prefix}: ABC-555", + "Signed-off-by: johndoe ", + f"{footer_prefix}: ABC-444", + ], + ), + ["ABC-444", "ABC-555"], + ), + # ----------------------------------------- Invalid Sets ----------------------------------------- # + # Must have colon because it is a git footer, these will not return a linked issue + (f"{footer_prefix} ABC-666", []), + (f"{footer_prefix} ABC-666, ABC-777", []), + # Invalid Multiple issues (although it is supported by GitHub, it is not supported by the parser) + (f"{footer_prefix} ABC-666, {footer_prefix} ABC-777", []), + # Invalid 'and' separation + (f"{footer_prefix}: ABC-666andABC-777", ["ABC-666andABC-777"]), + # Invalid prefix + ("ref: ABC-666", []), + # body mentions an issue and has a different git footer + ( + "In ABC-666, the devils in the details...\n\nSigned-off-by: johndoe ", + [], + ), + ] + ], + *[ + ( + f"feat(parser): add magic parser\n\n{footer}", + linked_issues, + ) + for footer, linked_issues in [ + # Multiple footers with the same issue but different prefixes + ("Resolves: #555\nfix: #444", ["#444", "#555"]), + # Whitespace padded footer + (" Resolves: #555\n", ["#555"]), + ] + ], + ( + # Only grabs the issue reference when there is a GitHub PR reference in the subject + "feat(parser): add magic parser (#123)\n\nCloses: #555", + ["#555"], + ), + # Does not grab an issue when there is only a GitHub PR reference in the subject + ("feat(parser): add magic parser (#123)", []), + # Does not grab an issue when there is only a Bitbucket PR reference in the subject + ("feat(parser): add magic parser (pull request #123)", []), + ], +) +def test_parser_return_linked_issues_from_commit_message( + default_angular_parser: AngularCommitParser, + message: str, + linked_issues: Sequence[str], + make_commit_obj: MakeCommitObjFn, +): + result = default_angular_parser.parse(make_commit_obj(message)) + assert isinstance(result, ParsedCommit) + assert tuple(linked_issues) == result.linked_issues + + ############################## # test custom parser options # ############################## From 1ada1b412ee549b60431fd7f327e1c9d001c4608 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sat, 30 Nov 2024 23:29:20 -0700 Subject: [PATCH 06/14] test(parser-emoji): add unit tests to verify parsing of issue numbers --- .../commit_parser/test_emoji.py | 284 +++++++++++++++++- 1 file changed, 283 insertions(+), 1 deletion(-) diff --git a/tests/unit/semantic_release/commit_parser/test_emoji.py b/tests/unit/semantic_release/commit_parser/test_emoji.py index 46e57970f..25065de9a 100644 --- a/tests/unit/semantic_release/commit_parser/test_emoji.py +++ b/tests/unit/semantic_release/commit_parser/test_emoji.py @@ -1,12 +1,14 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Sequence import pytest from semantic_release.commit_parser.token import ParsedCommit from semantic_release.enums import LevelBump +from tests.const import SUPPORTED_ISSUE_CLOSURE_PREFIXES + if TYPE_CHECKING: from semantic_release.commit_parser.emoji import EmojiCommitParser @@ -126,3 +128,283 @@ def test_parser_return_linked_merge_request_from_commit_message( assert isinstance(result, ParsedCommit) assert merge_request_number == result.linked_merge_request assert subject == result.descriptions[0] + + +@pytest.mark.parametrize( + "message, linked_issues", + # TODO: in v10, we will remove the issue reference footers from the descriptions + [ + *[ + # GitHub, Gitea, GitLab style + ( + f":sparkles: (parser) add magic parser\n\n{footer}", + linked_issues, + ) + for footer_prefix in SUPPORTED_ISSUE_CLOSURE_PREFIXES + for footer, linked_issues in [ + # Single issue + ( + f"{footer_prefix.capitalize()}: #555", + ["#555"], + ), # Git Footer style (capitalized) + (f"{footer_prefix.lower()}: #555", ["#555"]), # lowercase prefix + (f"{footer_prefix.upper()}: #555", ["#555"]), # uppercase prefix + # Mulitple issues (variant 1: list with one prefix, not supported by GitHub) + ( + f"{footer_prefix}: #555,#444", + ["#444", "#555"], + ), # Comma separated (w/o space) + ( + f"{footer_prefix}: #555, #444", + ["#444", "#555"], + ), # Comma separated (w/ space) + ( + f"{footer_prefix}: #555 , #444", + ["#444", "#555"], + ), # Comma separated (w/ extra space) + (f"{footer_prefix}: #555 #444", ["#444", "#555"]), # Space separated + ( + f"{footer_prefix}: #555;#444", + ["#444", "#555"], + ), # semicolon separated (w/o space) + ( + f"{footer_prefix}: #555; #444", + ["#444", "#555"], + ), # semicolon separated (w/ space) + ( + f"{footer_prefix}: #555 ; #444", + ["#444", "#555"], + ), # semicolon separated (w/ extra space) + ( + f"{footer_prefix}: #555/#444", + ["#444", "#555"], + ), # slash separated (w/o space) + ( + f"{footer_prefix}: #555/ #444", + ["#444", "#555"], + ), # slash separated (w/ space) + ( + f"{footer_prefix}: #555 / #444", + ["#444", "#555"], + ), # slash separated (w/ extra space) + ( + f"{footer_prefix}: #555Ƽ", + ["#444", "#555"], + ), # ampersand separated (w/o space) + ( + f"{footer_prefix}: #555& #444", + ["#444", "#555"], + ), # ampersand separated (w/ space) + ( + f"{footer_prefix}: #555 & #444", + ["#444", "#555"], + ), # ampersand separated (w/ extra space) + (f"{footer_prefix}: #555 and #444", ["#444", "#555"]), # and separated + ( + f"{footer_prefix}: #555, #444, and #333", + ["#333", "#444", "#555"], + ), # and separated + # Mulitple issues (variant 2: multiple footers, supported by GitHub) + (f"{footer_prefix}: #555\n{footer_prefix}: #444", ["#444", "#555"]), + # More than 2 issues + ( + f"{footer_prefix}: #555, #444, #333", + ["#333", "#444", "#555"], + ), # TODO: force ordering? + # Single issue listed multiple times + (f"{footer_prefix}: #555, #555", ["#555"]), + # Multiple footers with the same issue + (f"{footer_prefix}: #555\n{footer_prefix}: #555", ["#555"]), + # Multiple issues via multiple inline git footers + (f"{footer_prefix}: #555, {footer_prefix}: #444", ["#444", "#555"]), + # Multiple valid footers + ( + str.join( + "\n", + [ + f"{footer_prefix}: #555", + "Signed-off-by: johndoe ", + f"{footer_prefix}: #444", + ], + ), + ["#444", "#555"], + ), + # ----------------------------------------- Invalid Sets ----------------------------------------- # + # Must have colon because it is a git footer, these will not return a linked issue + (f"{footer_prefix} #666", []), + (f"{footer_prefix} #666, #777", []), + # Invalid Multiple issues (although it is supported by GitHub, it is not supported by the parser) + (f"{footer_prefix} #666, {footer_prefix} #777", []), + # Invalid 'and' separation + (f"{footer_prefix}: #666and#777", ["#666and#777"]), + # Invalid prefix + ("ref: #666", []), + # body mentions an issue and has a different git footer + ( + "In #666, the devils in the details...\n\nSigned-off-by: johndoe ", + [], + ), + ] + ], + *[ + # JIRA style + ( + f":sparkles: (parser) add magic parser\n\n{footer}", + linked_issues, + ) + for footer_prefix in SUPPORTED_ISSUE_CLOSURE_PREFIXES + for footer, linked_issues in [ + # Single issue + ( + f"{footer_prefix.capitalize()}: ABC-555", + ["ABC-555"], + ), # Git Footer style (capitalized) + (f"{footer_prefix.lower()}: ABC-555", ["ABC-555"]), # lowercase prefix + (f"{footer_prefix.upper()}: ABC-555", ["ABC-555"]), # uppercase prefix + # Mulitple issues (variant 1: list with one prefix, not supported by GitHub) + ( + f"{footer_prefix}: ABC-555,ABC-444", + ["ABC-444", "ABC-555"], + ), # Comma separated (w/o space) + ( + f"{footer_prefix}: ABC-555, ABC-444", + ["ABC-444", "ABC-555"], + ), # Comma separated (w/ space) + ( + f"{footer_prefix}: ABC-555 , ABC-444", + ["ABC-444", "ABC-555"], + ), # Comma separated (w/ extra space) + ( + f"{footer_prefix}: ABC-555 ABC-444", + ["ABC-444", "ABC-555"], + ), # Space separated + ( + f"{footer_prefix}: ABC-555;ABC-444", + ["ABC-444", "ABC-555"], + ), # semicolon separated (w/o space) + ( + f"{footer_prefix}: ABC-555; ABC-444", + ["ABC-444", "ABC-555"], + ), # semicolon separated (w/ space) + ( + f"{footer_prefix}: ABC-555 ; ABC-444", + ["ABC-444", "ABC-555"], + ), # semicolon separated (w/ extra space) + ( + f"{footer_prefix}: ABC-555/ABC-444", + ["ABC-444", "ABC-555"], + ), # slash separated (w/o space) + ( + f"{footer_prefix}: ABC-555/ ABC-444", + ["ABC-444", "ABC-555"], + ), # slash separated (w/ space) + ( + f"{footer_prefix}: ABC-555 / ABC-444", + ["ABC-444", "ABC-555"], + ), # slash separated (w/ extra space) + ( + f"{footer_prefix}: ABC-555&ABC-444", + ["ABC-444", "ABC-555"], + ), # ampersand separated (w/o space) + ( + f"{footer_prefix}: ABC-555& ABC-444", + ["ABC-444", "ABC-555"], + ), # ampersand separated (w/ space) + ( + f"{footer_prefix}: ABC-555 & ABC-444", + ["ABC-444", "ABC-555"], + ), # ampersand separated (w/ extra space) + ( + f"{footer_prefix}: ABC-555 and ABC-444", + ["ABC-444", "ABC-555"], + ), # and separated + ( + f"{footer_prefix}: ABC-555, ABC-444, and ABC-333", + ["ABC-333", "ABC-444", "ABC-555"], + ), # and separated + # Mulitple issues (variant 2: multiple footers, supported by GitHub) + ( + f"{footer_prefix}: ABC-555\n{footer_prefix}: ABC-444", + ["ABC-444", "ABC-555"], + ), + # More than 2 issues + ( + f"{footer_prefix}: ABC-555, ABC-444, ABC-333", + ["ABC-333", "ABC-444", "ABC-555"], + ), # TODO: force ordering? + # Single issue listed multiple times + (f"{footer_prefix}: ABC-555, ABC-555", ["ABC-555"]), + # Multiple footers with the same issue + (f"{footer_prefix}: ABC-555\n{footer_prefix}: ABC-555", ["ABC-555"]), + # Multiple issues via multiple inline git footers + ( + f"{footer_prefix}: ABC-666, {footer_prefix}: ABC-777", + ["ABC-666", "ABC-777"], + ), + # Multiple valid footers + ( + str.join( + "\n", + [ + f"{footer_prefix}: ABC-555", + "Signed-off-by: johndoe ", + f"{footer_prefix}: ABC-444", + ], + ), + ["ABC-444", "ABC-555"], + ), + # ----------------------------------------- Invalid Sets ----------------------------------------- # + # Must have colon because it is a git footer, these will not return a linked issue + (f"{footer_prefix} ABC-666", []), + (f"{footer_prefix} ABC-666, ABC-777", []), + # Invalid Multiple issues (although it is supported by GitHub, it is not supported by the parser) + (f"{footer_prefix} ABC-666, {footer_prefix} ABC-777", []), + # Invalid 'and' separation + (f"{footer_prefix}: ABC-666andABC-777", ["ABC-666andABC-777"]), + # Invalid prefix + ("ref: ABC-666", []), + # body mentions an issue and has a different git footer + ( + "In ABC-666, the devils in the details...\n\nSigned-off-by: johndoe ", + [], + ), + ] + ], + *[ + ( + f":sparkles: (parser) add magic parser\n\n{footer}", + linked_issues, + ) + for footer, linked_issues in [ + # Multiple footers with the same issue but different prefixes + ("Resolves: #555\nfix: #444", ["#444", "#555"]), + # Whitespace padded footer + (" Resolves: #555\n", ["#555"]), + ] + ], + ( + # Only grabs the issue reference when there is a GitHub PR reference in the subject + ":sparkles: (parser) add magic parser (#123)\n\nCloses: #555", + ["#555"], + ), + # Does not grab an issue when there is only a GitHub PR reference in the subject + (":sparkles: (parser) add magic parser (#123)", []), + # Does not grab an issue when there is only a Bitbucket PR reference in the subject + (":sparkles: (parser) add magic parser (pull request #123)", []), + ], +) +def test_parser_return_linked_issues_from_commit_message( + default_emoji_parser: EmojiCommitParser, + message: str, + linked_issues: Sequence[str], + make_commit_obj: MakeCommitObjFn, +): + # Setup: Enable parsing of linked issues + default_emoji_parser.options.parse_linked_issues = True + + # Action + result = default_emoji_parser.parse(make_commit_obj(message)) + + # Evaluate (expected -> actual) + assert isinstance(result, ParsedCommit) + assert tuple(linked_issues) == result.linked_issues From 337ed23c005b04bf0aa95f8cee9c6d8766555986 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sat, 30 Nov 2024 23:29:31 -0700 Subject: [PATCH 07/14] test(parser-scipy): add unit tests to verify parsing of issue numbers --- tests/fixtures/scipy.py | 2 +- .../commit_parser/test_scipy.py | 278 +++++++++++++++++- 2 files changed, 278 insertions(+), 2 deletions(-) diff --git a/tests/fixtures/scipy.py b/tests/fixtures/scipy.py index c19df3f0b..f9c704090 100644 --- a/tests/fixtures/scipy.py +++ b/tests/fixtures/scipy.py @@ -202,7 +202,7 @@ def scipy_nonbrking_commit_bodies() -> list[list[str]]: --- updated-dependencies: - - dependency-name: package + - dependency-name: package dependency-type: direct:development update-type: version-update:semver-major """ diff --git a/tests/unit/semantic_release/commit_parser/test_scipy.py b/tests/unit/semantic_release/commit_parser/test_scipy.py index f160bc8d8..77ade2ab5 100644 --- a/tests/unit/semantic_release/commit_parser/test_scipy.py +++ b/tests/unit/semantic_release/commit_parser/test_scipy.py @@ -1,7 +1,7 @@ from __future__ import annotations from re import compile as regexp -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Sequence import pytest @@ -9,6 +9,8 @@ from semantic_release.commit_parser.token import ParsedCommit from semantic_release.enums import LevelBump +from tests.const import SUPPORTED_ISSUE_CLOSURE_PREFIXES + if TYPE_CHECKING: from semantic_release.commit_parser.scipy import ScipyCommitParser @@ -182,3 +184,277 @@ def test_parser_return_linked_merge_request_from_commit_message( assert isinstance(result, ParsedCommit) assert merge_request_number == result.linked_merge_request assert subject == result.descriptions[0] + + +@pytest.mark.parametrize( + "message, linked_issues", + # TODO: in v10, we will remove the issue reference footers from the descriptions + [ + *[ + # GitHub, Gitea, GitLab style + ( + f"ENH: add magic parser\n\n{footer}", + linked_issues, + ) + for footer_prefix in SUPPORTED_ISSUE_CLOSURE_PREFIXES + for footer, linked_issues in [ + # Single issue + ( + f"{footer_prefix.capitalize()}: #555", + ["#555"], + ), # Git Footer style (capitalized) + (f"{footer_prefix.lower()}: #555", ["#555"]), # lowercase prefix + (f"{footer_prefix.upper()}: #555", ["#555"]), # uppercase prefix + # Mulitple issues (variant 1: list with one prefix, not supported by GitHub) + ( + f"{footer_prefix}: #555,#444", + ["#444", "#555"], + ), # Comma separated (w/o space) + ( + f"{footer_prefix}: #555, #444", + ["#444", "#555"], + ), # Comma separated (w/ space) + ( + f"{footer_prefix}: #555 , #444", + ["#444", "#555"], + ), # Comma separated (w/ extra space) + (f"{footer_prefix}: #555 #444", ["#444", "#555"]), # Space separated + ( + f"{footer_prefix}: #555;#444", + ["#444", "#555"], + ), # semicolon separated (w/o space) + ( + f"{footer_prefix}: #555; #444", + ["#444", "#555"], + ), # semicolon separated (w/ space) + ( + f"{footer_prefix}: #555 ; #444", + ["#444", "#555"], + ), # semicolon separated (w/ extra space) + ( + f"{footer_prefix}: #555/#444", + ["#444", "#555"], + ), # slash separated (w/o space) + ( + f"{footer_prefix}: #555/ #444", + ["#444", "#555"], + ), # slash separated (w/ space) + ( + f"{footer_prefix}: #555 / #444", + ["#444", "#555"], + ), # slash separated (w/ extra space) + ( + f"{footer_prefix}: #555Ƽ", + ["#444", "#555"], + ), # ampersand separated (w/o space) + ( + f"{footer_prefix}: #555& #444", + ["#444", "#555"], + ), # ampersand separated (w/ space) + ( + f"{footer_prefix}: #555 & #444", + ["#444", "#555"], + ), # ampersand separated (w/ extra space) + (f"{footer_prefix}: #555 and #444", ["#444", "#555"]), # and separated + ( + f"{footer_prefix}: #555, #444, and #333", + ["#333", "#444", "#555"], + ), # and separated + # Mulitple issues (variant 2: multiple footers, supported by GitHub) + (f"{footer_prefix}: #555\n{footer_prefix}: #444", ["#444", "#555"]), + # More than 2 issues + ( + f"{footer_prefix}: #555, #444, #333", + ["#333", "#444", "#555"], + ), # TODO: force ordering? + # Single issue listed multiple times + (f"{footer_prefix}: #555, #555", ["#555"]), + # Multiple footers with the same issue + (f"{footer_prefix}: #555\n{footer_prefix}: #555", ["#555"]), + # Multiple issues via multiple inline git footers + (f"{footer_prefix}: #555, {footer_prefix}: #444", ["#444", "#555"]), + # Multiple valid footers + ( + str.join( + "\n", + [ + f"{footer_prefix}: #555", + "Signed-off-by: johndoe ", + f"{footer_prefix}: #444", + ], + ), + ["#444", "#555"], + ), + # ----------------------------------------- Invalid Sets ----------------------------------------- # + # Must have colon because it is a git footer, these will not return a linked issue + (f"{footer_prefix} #666", []), + (f"{footer_prefix} #666, #777", []), + # Invalid Multiple issues (although it is supported by GitHub, it is not supported by the parser) + (f"{footer_prefix} #666, {footer_prefix} #777", []), + # Invalid 'and' separation + (f"{footer_prefix}: #666and#777", ["#666and#777"]), + # Invalid prefix + ("ref: #666", []), + # body mentions an issue and has a different git footer + ( + "In #666, the devils in the details...\n\nSigned-off-by: johndoe ", + [], + ), + ] + ], + *[ + # JIRA style + ( + f"ENH(parser): add magic parser\n\n{footer}", + linked_issues, + ) + for footer_prefix in SUPPORTED_ISSUE_CLOSURE_PREFIXES + for footer, linked_issues in [ + # Single issue + ( + f"{footer_prefix.capitalize()}: ABC-555", + ["ABC-555"], + ), # Git Footer style (capitalized) + (f"{footer_prefix.lower()}: ABC-555", ["ABC-555"]), # lowercase prefix + (f"{footer_prefix.upper()}: ABC-555", ["ABC-555"]), # uppercase prefix + # Mulitple issues (variant 1: list with one prefix, not supported by GitHub) + ( + f"{footer_prefix}: ABC-555,ABC-444", + ["ABC-444", "ABC-555"], + ), # Comma separated (w/o space) + ( + f"{footer_prefix}: ABC-555, ABC-444", + ["ABC-444", "ABC-555"], + ), # Comma separated (w/ space) + ( + f"{footer_prefix}: ABC-555 , ABC-444", + ["ABC-444", "ABC-555"], + ), # Comma separated (w/ extra space) + ( + f"{footer_prefix}: ABC-555 ABC-444", + ["ABC-444", "ABC-555"], + ), # Space separated + ( + f"{footer_prefix}: ABC-555;ABC-444", + ["ABC-444", "ABC-555"], + ), # semicolon separated (w/o space) + ( + f"{footer_prefix}: ABC-555; ABC-444", + ["ABC-444", "ABC-555"], + ), # semicolon separated (w/ space) + ( + f"{footer_prefix}: ABC-555 ; ABC-444", + ["ABC-444", "ABC-555"], + ), # semicolon separated (w/ extra space) + ( + f"{footer_prefix}: ABC-555/ABC-444", + ["ABC-444", "ABC-555"], + ), # slash separated (w/o space) + ( + f"{footer_prefix}: ABC-555/ ABC-444", + ["ABC-444", "ABC-555"], + ), # slash separated (w/ space) + ( + f"{footer_prefix}: ABC-555 / ABC-444", + ["ABC-444", "ABC-555"], + ), # slash separated (w/ extra space) + ( + f"{footer_prefix}: ABC-555&ABC-444", + ["ABC-444", "ABC-555"], + ), # ampersand separated (w/o space) + ( + f"{footer_prefix}: ABC-555& ABC-444", + ["ABC-444", "ABC-555"], + ), # ampersand separated (w/ space) + ( + f"{footer_prefix}: ABC-555 & ABC-444", + ["ABC-444", "ABC-555"], + ), # ampersand separated (w/ extra space) + ( + f"{footer_prefix}: ABC-555 and ABC-444", + ["ABC-444", "ABC-555"], + ), # and separated + ( + f"{footer_prefix}: ABC-555, ABC-444, and ABC-333", + ["ABC-333", "ABC-444", "ABC-555"], + ), # and separated + # Mulitple issues (variant 2: multiple footers, supported by GitHub) + ( + f"{footer_prefix}: ABC-555\n{footer_prefix}: ABC-444", + ["ABC-444", "ABC-555"], + ), + # More than 2 issues + ( + f"{footer_prefix}: ABC-555, ABC-444, ABC-333", + ["ABC-333", "ABC-444", "ABC-555"], + ), # TODO: force ordering? + # Single issue listed multiple times + (f"{footer_prefix}: ABC-555, ABC-555", ["ABC-555"]), + # Multiple footers with the same issue + (f"{footer_prefix}: ABC-555\n{footer_prefix}: ABC-555", ["ABC-555"]), + # Multiple issues via multiple inline git footers + ( + f"{footer_prefix}: ABC-666, {footer_prefix}: ABC-777", + ["ABC-666", "ABC-777"], + ), + # Multiple valid footers + ( + str.join( + "\n", + [ + f"{footer_prefix}: ABC-555", + "Signed-off-by: johndoe ", + f"{footer_prefix}: ABC-444", + ], + ), + ["ABC-444", "ABC-555"], + ), + # ----------------------------------------- Invalid Sets ----------------------------------------- # + # Must have colon because it is a git footer, these will not return a linked issue + (f"{footer_prefix} ABC-666", []), + (f"{footer_prefix} ABC-666, ABC-777", []), + # Invalid Multiple issues (although it is supported by GitHub, it is not supported by the parser) + (f"{footer_prefix} ABC-666, {footer_prefix} ABC-777", []), + # Invalid 'and' separation + (f"{footer_prefix}: ABC-666andABC-777", ["ABC-666andABC-777"]), + # Invalid prefix + ("ref: ABC-666", []), + # body mentions an issue and has a different git footer + ( + "In ABC-666, the devils in the details...\n\nSigned-off-by: johndoe ", + [], + ), + ] + ], + *[ + ( + f"ENH(parser): add magic parser\n\n{footer}", + linked_issues, + ) + for footer, linked_issues in [ + # Multiple footers with the same issue but different prefixes + ("Resolves: #555\nfix: #444", ["#444", "#555"]), + # Whitespace padded footer + (" Resolves: #555\n", ["#555"]), + ] + ], + ( + # Only grabs the issue reference when there is a GitHub PR reference in the subject + "ENH(parser): add magic parser (#123)\n\nCloses: #555", + ["#555"], + ), + # Does not grab an issue when there is only a GitHub PR reference in the subject + ("ENH(parser): add magic parser (#123)", []), + # Does not grab an issue when there is only a Bitbucket PR reference in the subject + ("ENH(parser): add magic parser (pull request #123)", []), + ], +) +def test_parser_return_linked_issues_from_commit_message( + default_scipy_parser: ScipyCommitParser, + message: str, + linked_issues: Sequence[str], + make_commit_obj: MakeCommitObjFn, +): + result = default_scipy_parser.parse(make_commit_obj(message)) + assert isinstance(result, ParsedCommit) + assert tuple(linked_issues) == result.linked_issues From 3fb324d8682fa561827f412fb40ba66c06ab2772 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sat, 30 Nov 2024 23:28:16 -0700 Subject: [PATCH 08/14] fix(util): prevent git footers from being collapsed during parse --- src/semantic_release/commit_parser/util.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/semantic_release/commit_parser/util.py b/src/semantic_release/commit_parser/util.py index 8ad6b9773..9d53e0af4 100644 --- a/src/semantic_release/commit_parser/util.py +++ b/src/semantic_release/commit_parser/util.py @@ -1,7 +1,7 @@ from __future__ import annotations from functools import reduce -from re import compile as regexp +from re import MULTILINE, compile as regexp from typing import TYPE_CHECKING if TYPE_CHECKING: # pragma: no cover @@ -28,6 +28,13 @@ class RegexReplaceDef(TypedDict): "pattern": regexp(r"[\r\t\f\v ]*\r?\n"), "repl": "\n", # remove the optional whitespace & remove windows newlines } +spread_out_git_footers: RegexReplaceDef = { + # Match a git footer line, and add an extra newline after it + # only be flexible enough for a double space indent (otherwise its probably on purpose) + # - found collision with dependabot's yaml in a commit message with a git footer (its unusal but possible) + "pattern": regexp(r"^ {0,2}([\w-]*: .+)$\n?(?!\n)", MULTILINE), + "repl": r"\1\n\n", +} def parse_paragraphs(text: str) -> list[str]: @@ -38,12 +45,14 @@ def parse_paragraphs(text: str) -> list[str]: To handle Windows line endings, carriage returns '\r' are removed before separating into paragraphs. + It will attempt to detect Git footers and they will not be condensed. + :param text: The text string to be divided. :return: A list of condensed paragraphs, as strings. """ adjusted_text = reduce( lambda txt, adj: adj["pattern"].sub(adj["repl"], txt), - [trim_line_endings, un_word_wrap_hyphen], + [trim_line_endings, un_word_wrap_hyphen, spread_out_git_footers], text, ) @@ -52,7 +61,7 @@ def parse_paragraphs(text: str) -> list[str]: None, [ un_word_wrap["pattern"].sub(un_word_wrap["repl"], paragraph).strip() - for paragraph in adjusted_text.split("\n\n") + for paragraph in adjusted_text.strip().split("\n\n") ], ) ) From 126198f5c92045ece3b47f101b7ae04b8456b5fb Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Wed, 23 Oct 2024 23:20:24 -0600 Subject: [PATCH 09/14] feat(parser-angular): automatically parse angular issue footers from commit messages --- src/semantic_release/commit_parser/angular.py | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/src/semantic_release/commit_parser/angular.py b/src/semantic_release/commit_parser/angular.py index 4cc1e82d1..2a846527d 100644 --- a/src/semantic_release/commit_parser/angular.py +++ b/src/semantic_release/commit_parser/angular.py @@ -157,6 +157,16 @@ def __init__(self, options: AngularParserOptions | None = None) -> None: self.mr_selector = regexp( r"[\t ]+\((?:pull request )?(?P[#!]\d+)\)[\t ]*$" ) + self.issue_selector = regexp( + str.join( + "", + [ + r"^(?:clos(?:e|es|ed|ing)|fix(?:es|ed|ing)?|resolv(?:e|es|ed|ing)|implement(?:s|ed|ing)?):", + r"[\t ]+(?P.+)[\t ]*$", + ], + ), + flags=re.MULTILINE | re.IGNORECASE, + ) @staticmethod def get_default_options() -> AngularParserOptions: @@ -170,7 +180,31 @@ def commit_body_components_separator( # TODO: breaking change v10, removes breaking change footers from descriptions # return accumulator - accumulator["descriptions"].append(text) + elif match := self.issue_selector.search(text): + # if match := self.issue_selector.search(text): + predicate = regexp(r",? and | *[,;/& ] *").sub( + ",", match.group("issue_predicate") or "" + ) + # Almost all issue trackers use a number to reference an issue so + # we use a simple regexp to validate the existence of a number which helps filter out + # any non-issue references that don't fit our expected format + has_number = regexp(r"\d+") + new_issue_refs: set[str] = set( + filter( + lambda issue_str, validator=has_number: validator.search(issue_str), # type: ignore[arg-type] + predicate.split(","), + ) + ) + accumulator["linked_issues"] = sorted( + set(accumulator["linked_issues"]).union(new_issue_refs) + ) + # TODO: breaking change v10, removes resolution footers from descriptions + # return accumulator + + # Prevent appending duplicate descriptions + if text not in accumulator["descriptions"]: + accumulator["descriptions"].append(text) + return accumulator def parse_message(self, message: str) -> ParsedMessageResult | None: @@ -200,6 +234,7 @@ def parse_message(self, message: str) -> ParsedMessageResult | None: { "breaking_descriptions": [], "descriptions": [], + "linked_issues": [], }, ) @@ -219,6 +254,7 @@ def parse_message(self, message: str) -> ParsedMessageResult | None: scope=parsed_scope, descriptions=tuple(body_components["descriptions"]), breaking_descriptions=tuple(body_components["breaking_descriptions"]), + linked_issues=tuple(body_components["linked_issues"]), linked_merge_request=linked_merge_request, ) From 617cce3addb163cc3c86ef52f32ac081475ee6cb Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Mon, 11 Nov 2024 16:34:29 -0700 Subject: [PATCH 10/14] feat(parser-emoji): parse issue reference footers from commit messages --- src/semantic_release/commit_parser/emoji.py | 69 ++++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/src/semantic_release/commit_parser/emoji.py b/src/semantic_release/commit_parser/emoji.py index 798be78d6..73c1961e4 100644 --- a/src/semantic_release/commit_parser/emoji.py +++ b/src/semantic_release/commit_parser/emoji.py @@ -4,6 +4,7 @@ import logging import re +from functools import reduce from itertools import zip_longest from re import compile as regexp from typing import Tuple @@ -78,6 +79,19 @@ def tag_to_level(self) -> dict[str, LevelBump]: """A mapping of commit tags to the level bump they should result in.""" return self._tag_to_level + parse_linked_issues: bool = False + """ + Whether to parse linked issues from the commit message. + + Issue identification is not defined in the Gitmoji specification, so this parser + will not attempt to parse issues by default. If enabled, the parser will use the + same identification as GitHub, GitLab, and BitBucket use for linking issues, which + is to look for a git commit message footer starting with "Closes:", "Fixes:", + or "Resolves:" then a space, and then the issue identifier. The line prefix + can be singular or plural and it is not case-sensitive but must have a colon and + a whitespace separator. + """ + def __post_init__(self) -> None: self._tag_to_level: dict[str, LevelBump] = { str(tag): level @@ -138,10 +152,52 @@ def __init__(self, options: EmojiParserOptions | None = None) -> None: r"[\t ]+\((?:pull request )?(?P[#!]\d+)\)[\t ]*$" ) + self.issue_selector = regexp( + str.join( + "", + [ + r"^(?:clos(?:e|es|ed|ing)|fix(?:es|ed|ing)?|resolv(?:e|es|ed|ing)|implement(?:s|ed|ing)?):", + r"[\t ]+(?P.+)[\t ]*$", + ], + ), + flags=re.MULTILINE | re.IGNORECASE, + ) + @staticmethod def get_default_options() -> EmojiParserOptions: return EmojiParserOptions() + def commit_body_components_separator( + self, accumulator: dict[str, list[str]], text: str + ) -> dict[str, list[str]]: + if self.options.parse_linked_issues and ( + match := self.issue_selector.search(text) + ): + predicate = regexp(r",? and | *[,;/& ] *").sub( + ",", match.group("issue_predicate") or "" + ) + # Almost all issue trackers use a number to reference an issue so + # we use a simple regexp to validate the existence of a number which helps filter out + # any non-issue references that don't fit our expected format + has_number = regexp(r"\d+") + new_issue_refs: set[str] = set( + filter( + lambda issue_str, validator=has_number: validator.search(issue_str), # type: ignore[arg-type] + predicate.split(","), + ) + ) + accumulator["linked_issues"] = sorted( + set(accumulator["linked_issues"]).union(new_issue_refs) + ) + # TODO: breaking change v10, removes resolution footers from descriptions + # return accumulator + + # Prevent appending duplicate descriptions + if text not in accumulator["descriptions"]: + accumulator["descriptions"].append(text) + + return accumulator + def parse_message(self, message: str) -> ParsedMessageResult: subject = message.split("\n", maxsplit=1)[0] @@ -164,7 +220,17 @@ def parse_message(self, message: str) -> ParsedMessageResult: ) # All emojis will remain part of the returned description - descriptions = tuple(parse_paragraphs(message)) + body_components: dict[str, list[str]] = reduce( + self.commit_body_components_separator, + parse_paragraphs(message), + { + "descriptions": [], + "linked_issues": [], + }, + ) + + descriptions = tuple(body_components["descriptions"]) + return ParsedMessageResult( bump=level_bump, type=primary_emoji, @@ -178,6 +244,7 @@ def parse_message(self, message: str) -> ParsedMessageResult: breaking_descriptions=( descriptions[1:] if level_bump is LevelBump.MAJOR else () ), + linked_issues=tuple(body_components["linked_issues"]), linked_merge_request=linked_merge_request, ) From f26046bdbf87d6948bea9f8a8966fe9004b26036 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sun, 1 Dec 2024 16:08:39 -0700 Subject: [PATCH 11/14] docs(commit-parsing): improve & expand commit parsing w/ parser descriptions --- docs/commit-parsing.rst | 388 -------------------------------- docs/commit_parsing.rst | 488 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 488 insertions(+), 388 deletions(-) delete mode 100644 docs/commit-parsing.rst create mode 100644 docs/commit_parsing.rst diff --git a/docs/commit-parsing.rst b/docs/commit-parsing.rst deleted file mode 100644 index dc5d88afe..000000000 --- a/docs/commit-parsing.rst +++ /dev/null @@ -1,388 +0,0 @@ -.. _commit-parsing: - -Commit Parsing -============== - -The semver level that should be bumped on a release is determined by the -commit messages since the last release. In order to be able to decide the correct -version and generate the changelog, the content of those commit messages must -be parsed. By default this package uses a parser for the Angular commit message -style:: - - (): - - - -