Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,8 @@ Add the following step to your GitHub workflow (in example are used non-default
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag-name: "v0.2.0"
from-tag-name: "v0.1.0"
tag-name: "v0.2.0" # accepts also v0.2 format when patch version is 0
from-tag-name: "v0.1.0" # accepts also v0.1 format when patch version is 0
chapters: |
- {"title": "Breaking Changes 💥", "label": "breaking-change"}
- {"title": "New Features 🎉", "label": "enhancement"}
Expand Down
8 changes: 6 additions & 2 deletions release_notes_generator/action_inputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
)
from release_notes_generator.utils.enums import DuplicityScopeEnum
from release_notes_generator.utils.gh_action import get_action_input
from release_notes_generator.utils.utils import normalize_version_tag

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -119,14 +120,16 @@ def get_tag_name() -> str:
"""
Get the tag name from the action inputs.
"""
return get_action_input(TAG_NAME) or ""
raw = get_action_input(TAG_NAME) or ""
return normalize_version_tag(raw)

@staticmethod
def get_from_tag_name() -> str:
"""
Get the from-tag name from the action inputs.
"""
return get_action_input(FROM_TAG_NAME, default="") # type: ignore[return-value] # string is returned as default
raw = get_action_input(FROM_TAG_NAME, default="")
return normalize_version_tag(raw) # type: ignore[arg-type]

@staticmethod
def is_from_tag_name_defined() -> bool:
Expand Down Expand Up @@ -416,6 +419,7 @@ def validate_inputs() -> None:

logger.debug("Repository: %s/%s", ActionInputs._owner, ActionInputs._repo_name)
logger.debug("Tag name: %s", tag_name)
logger.debug("From tag name: %s", from_tag_name)
logger.debug("Chapters: %s", chapters)
logger.debug("Published at: %s", published_at)
logger.debug("Skip release notes labels: %s", ActionInputs.get_skip_release_notes_labels())
Expand Down
44 changes: 44 additions & 0 deletions release_notes_generator/utils/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"""

import logging
import re

from typing import Optional

Expand Down Expand Up @@ -54,3 +55,46 @@ def get_change_url(
changelog_url = f"https://github.com/{repo.full_name}/compare/{rls.tag_name}...{tag_name}"

return changelog_url


_SEMVER_SHORT_RE = re.compile(
r"""
^\s* # optional leading whitespace
v? # optional leading 'v'
(?P<major>\d+) # major
\. # dot
(?P<minor>\d+) # minor
(?:\.(?P<patch>\d+))? # optional .patch
\s*$ # optional trailing whitespace
""",
re.VERBOSE,
)
Comment on lines +60 to +71
Copy link

@coderabbitai coderabbitai bot Aug 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

⚠️ Potential issue

Avoid enforcing lowercase 'v'; support uppercase 'V', pre-release, and build metadata.

Current regex disallows common tags like V1.2.3, v1.2.3-rc.1, or v1.2.3+build. It also makes it hard to preserve/omit the prefix consistently.

-_SEMVER_SHORT_RE = re.compile(
-    r"""
-    ^\s*                # optional leading whitespace
-    v?                  # optional leading 'v'
-    (?P<major>\d+)      # major
-    \.                  # dot
-    (?P<minor>\d+)      # minor
-    (?:\.(?P<patch>\d+))?  # optional .patch
-    \s*$                # optional trailing whitespace
-""",
-    re.VERBOSE,
-)
+_SEMVER_SHORT_RE = re.compile(
+    r"""
+    ^\s*
+    (?P<prefix>[vV])?                  # optional leading 'v' or 'V'
+    (?P<major>0|[1-9]\d*)              # major (no leading zeros unless zero)
+    \.
+    (?P<minor>0|[1-9]\d*)              # minor
+    (?:\.(?P<patch>0|[1-9]\d*))?       # optional .patch
+    (?:-(?P<prerelease>[0-9A-Za-z.-]+))?  # optional -prerelease
+    (?:\+(?P<build>[0-9A-Za-z.-]+))?      # optional +build
+    \s*$
+""",
+    re.VERBOSE,
+)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
_SEMVER_SHORT_RE = re.compile(
r"""
^\s* # optional leading whitespace
v? # optional leading 'v'
(?P<major>\d+) # major
\. # dot
(?P<minor>\d+) # minor
(?:\.(?P<patch>\d+))? # optional .patch
\s*$ # optional trailing whitespace
""",
re.VERBOSE,
)
_SEMVER_SHORT_RE = re.compile(
r"""
^\s*
(?P<prefix>[vV])? # optional leading 'v' or 'V'
(?P<major>0|[1-9]\d*) # major (no leading zeros unless zero)
\.
(?P<minor>0|[1-9]\d*) # minor
(?:\.(?P<patch>0|[1-9]\d*))? # optional .patch
(?:-(?P<prerelease>[0-9A-Za-z.-]+))? # optional -prerelease
(?:\+(?P<build>[0-9A-Za-z.-]+))? # optional +build
\s*$
""",
re.VERBOSE,
)
🤖 Prompt for AI Agents
In release_notes_generator/utils/utils.py around lines 60 to 71, the SEMVER
short regex currently only accepts a lowercase 'v' and no pre-release or build
metadata; update the pattern to accept an optional prefix (v or V) and optional
pre-release (-...) and build (+...) components and capture them with named
groups so the caller can consistently preserve or omit the prefix and metadata.
Concretely: change the leading v? to accept either case (use re.IGNORECASE or
[vV] and/or add a named group like (?P<prefix>v)?), add optional named groups
(?P<prerelease>-(?:[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))? and
(?P<build>\+(?:[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))? after the patch, keep patch
optional as before, and retain re.VERBOSE (or combine with re.IGNORECASE) so the
function can parse tags like V1.2.3, v1.2.3-rc.1, and v1.2.3+build and return
consistent captures for prefix, prerelease, and build.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I want to like it is now.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@miroslavpojer, understood! I respect your decision to keep the regex pattern simple and focused on your specific use case. The current implementation effectively handles the core requirement of allowing patch versions to be omitted while defaulting to .0, which aligns perfectly with the PR objectives.

Keeping it straightforward and avoiding additional complexity like pre-release versions or build metadata makes sense for your current needs.

🐰
nodding thoughtfully


✏️ Learnings added
Learnt from: miroslavpojer
PR: AbsaOSS/generate-release-notes#158
File: release_notes_generator/utils/utils.py:60-71
Timestamp: 2025-08-28T08:39:30.135Z
Learning: In the generate-release-notes project, the user miroslavpojer prefers to keep the SEMVER regex pattern simple, focusing only on basic major.minor[.patch] format with lowercase 'v' prefix support, rather than expanding it to handle uppercase 'V', pre-release versions, or build metadata.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.



def normalize_version_tag(tag: str) -> str:
"""
Normalize a tag to full 'vMAJOR.MINOR.PATCH' form.

Accepts:
- 'v1.2.3' -> 'v1.2.3'
- 'v1.2' -> 'v1.2.0'
- '1.2.3' -> 'v1.2.3'
- '1.2' -> 'v1.2.0'

Returns empty string if input is empty/whitespace.
Raises ValueError on malformed versions.
"""
if not tag or tag.strip() == "":
return ""

m = _SEMVER_SHORT_RE.match(tag)
if not m:
raise ValueError(
f"Invalid version tag format: {tag!r}. " "Expected vMAJOR.MINOR[.PATCH], e.g. 'v0.2' or 'v0.2.0'."
)

major = int(m.group("major"))
minor = int(m.group("minor"))
patch = int(m.group("patch")) if m.group("patch") is not None else 0

return f"v{major}.{minor}.{patch}"
51 changes: 50 additions & 1 deletion tests/test_action_inputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,11 +108,60 @@ def test_get_github_token(mocker):
assert ActionInputs.get_github_token() == "fake-token"


def test_get_tag_name(mocker):
def test_get_tag_name_version_full(mocker):
mocker.patch("release_notes_generator.action_inputs.get_action_input", return_value="v1.0.0")
assert ActionInputs.get_tag_name() == "v1.0.0"


def test_get_tag_name_version_shorted_with_v(mocker):
mocker.patch("release_notes_generator.action_inputs.get_action_input", return_value="v1.2")
assert ActionInputs.get_tag_name() == "v1.2.0"


def test_get_tag_name_version_shorted_no_v(mocker):
mocker.patch("release_notes_generator.action_inputs.get_action_input", return_value="1.2")
assert ActionInputs.get_tag_name() == "v1.2.0"


def test_get_tag_name_empty(mocker):
mocker.patch("release_notes_generator.action_inputs.get_action_input", return_value="")
assert ActionInputs.get_tag_name() == ""


def test_get_tag_name_invalid_format(mocker):
mocker.patch("release_notes_generator.action_inputs.get_action_input", return_value="v1.2.beta")
with pytest.raises(ValueError) as excinfo:
ActionInputs.get_tag_name()
assert "Invalid version tag format: 'v1.2.beta'. Expected vMAJOR.MINOR[.PATCH], e.g. 'v0.2' or 'v0.2.0'." in str(excinfo.value)


def test_get_tag_from_name_version_full(mocker):
mocker.patch("release_notes_generator.action_inputs.get_action_input", return_value="v1.0.0")
assert ActionInputs.get_from_tag_name() == "v1.0.0"


def test_get_from_tag_name_version_shorted_with_v(mocker):
mocker.patch("release_notes_generator.action_inputs.get_action_input", return_value="v1.2")
assert ActionInputs.get_from_tag_name() == "v1.2.0"


def test_get_from_tag_name_version_shorted_no_v(mocker):
mocker.patch("release_notes_generator.action_inputs.get_action_input", return_value="1.2")
assert ActionInputs.get_from_tag_name() == "v1.2.0"


def test_get_from_tag_name_empty(mocker):
mocker.patch("release_notes_generator.action_inputs.get_action_input", return_value="")
assert ActionInputs.get_from_tag_name() == ""


def test_get_from_tag_name_invalid_format(mocker):
mocker.patch("release_notes_generator.action_inputs.get_action_input", return_value="v1.2.beta")
with pytest.raises(ValueError) as excinfo:
ActionInputs.get_from_tag_name()
assert "Invalid version tag format: 'v1.2.beta'. Expected vMAJOR.MINOR[.PATCH], e.g. 'v0.2' or 'v0.2.0'." in str(excinfo.value)


def test_get_chapters_success(mocker):
mocker.patch("release_notes_generator.action_inputs.get_action_input", return_value="[{\"title\": \"Title\", \"label\": \"Label\"}]")
assert ActionInputs.get_chapters() == [{"title": "Title", "label": "Label"}]
Expand Down
Loading