Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/120 change latest tag selection from time to semantic #124

17 changes: 16 additions & 1 deletion .github/workflows/release_draft.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ on:
tag-name:
description: 'Name of git tag to be created, and then draft release created. Syntax: "v[0-9]+.[0-9]+.[0-9]+".'
required: true
from-tag-name:
description: 'Name of the git tag from which to detect changes from. Default value: latest tag. Syntax: "v[0-9]+.[0-9]+.[0-9]+".'
required: false

jobs:
release-draft:
Expand All @@ -37,20 +40,32 @@ jobs:

- name: Check format of received tag
id: check-version-tag
uses: AbsaOSS/version-tag-check@v0.2.0
uses: AbsaOSS/version-tag-check@v0.3.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
github-repository: ${{ github.repository }}
version-tag: ${{ github.event.inputs.tag-name }}

- name: Check format of received from tag
if: ${{ github.event.inputs.from-tag-name }}
id: check-version-from-tag
uses: AbsaOSS/[email protected]
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
github-repository: ${{ github.repository }}
version-tag: ${{ github.event.inputs.from-tag-name }}
should-exist: true

- name: Generate Release Notes
id: generate_release_notes
uses: AbsaOSS/generate-release-notes@master
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag-name: ${{ github.event.inputs.tag-name }}
from-tag-name: ${{ github.event.inputs.from-tag-name }}
chapters: '[
{"title": "No entry 🚫", "label": "duplicate"},
{"title": "No entry 🚫", "label": "invalid"},
Expand Down
5 changes: 5 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ inputs:
description: 'Allow duplicity of issue lines in chapters. Scopes: custom, service, both, none.'
required: false
default: 'both'
from-tag-name:
description: 'The tag name of the previous release to use as a start reference point for the current release notes.'
required: false
default: ''
duplicity-icon:
description: 'Icon to be used for duplicity warning. Icon is placed before the record line.'
required: false
Expand Down Expand Up @@ -115,6 +119,7 @@ runs:
INPUT_GITHUB_TOKEN: ${{ env.GITHUB_TOKEN }}
INPUT_TAG_NAME: ${{ inputs.tag-name }}
INPUT_CHAPTERS: ${{ inputs.chapters }}
INPUT_FROM_TAG_NAME: ${{ inputs.from-tag-name }}
INPUT_DUPLICITY_SCOPE: ${{ inputs.duplicity-scope }}
INPUT_DUPLICITY_ICON: ${{ inputs.duplicity-icon }}
INPUT_WARNINGS: ${{ inputs.warnings }}
Expand Down
18 changes: 16 additions & 2 deletions examples/release_draft.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ on:
tag-name:
description: 'Name of git tag to be created, and then draft release created. Syntax: "v[0-9]+.[0-9]+.[0-9]+".'
required: true
from-tag-name:
description: 'Name of the git tag from which to detect changes from. Default value: latest tag. Syntax: "v[0-9]+.[0-9]+.[0-9]+".'
required: false

jobs:
release-draft:
Expand All @@ -23,21 +26,32 @@ jobs:

- name: Check format of received tag
id: check-version-tag
uses: AbsaOSS/version-tag-check@v0.1.0
uses: AbsaOSS/version-tag-check@v0.3.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
github-repository: ${{ github.repository }}
branch: 'master'
version-tag: ${{ github.event.inputs.tag-name }}

- name: Check format of received from tag
if: ${{ github.event.inputs.from-tag-name }}
id: check-version-from-tag
uses: AbsaOSS/[email protected]
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
github-repository: ${{ github.repository }}
version-tag: ${{ github.event.inputs.from-tag-name }}
should-exist: true

- name: Generate Release Notes
id: generate_release_notes
uses: AbsaOSS/[email protected]
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag-name: ${{ github.event.inputs.tag-name }}
from-tag-name: ${{ github.event.inputs.from-tag-name }}
chapters: '[
{"title": "No entry 🚫", "label": "duplicate"},
{"title": "No entry 🚫", "label": "invalid"},
Expand Down
32 changes: 27 additions & 5 deletions release_notes_generator/action_inputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
RELEASE_NOTES_TITLE,
RELEASE_NOTE_TITLE_DEFAULT,
SUPPORTED_ROW_FORMAT_KEYS,
FROM_TAG_NAME,
)
from release_notes_generator.utils.enums import DuplicityScopeEnum
from release_notes_generator.utils.gh_action import get_action_input
Expand Down Expand Up @@ -81,6 +82,21 @@ def get_tag_name() -> str:
"""
return get_action_input(TAG_NAME)

@staticmethod
def get_from_tag_name() -> str:
"""
Get the from-tag name from the action inputs.
"""
return get_action_input(FROM_TAG_NAME, default="")

@staticmethod
def is_from_tag_name_defined() -> bool:
"""
Check if the from-tag name is defined in the action inputs.
"""
value = ActionInputs.get_from_tag_name()
return value.strip() != ""

@staticmethod
def get_chapters_json() -> str:
"""
Expand Down Expand Up @@ -120,8 +136,9 @@ def get_skip_release_notes_labels() -> str:
"""
Get the skip release notes label from the action inputs.
"""
user_choice = [item.strip() for item in get_action_input(SKIP_RELEASE_NOTES_LABELS, "").split(",")]
if len(user_choice) > 0:
user_input = get_action_input(SKIP_RELEASE_NOTES_LABELS, "")
user_choice = [item.strip() for item in user_input.split(",")] if user_input else []
if user_choice:
return user_choice
return ["skip-release-notes"]

Expand Down Expand Up @@ -222,6 +239,10 @@ def validate_inputs() -> None:
if not isinstance(tag_name, str) or not tag_name.strip():
errors.append("Tag name must be a non-empty string.")

from_tag_name = ActionInputs.get_from_tag_name()
if not isinstance(from_tag_name, str):
errors.append("From tag name must be a string.")

chapters_json = ActionInputs.get_chapters_json()
try:
json.loads(chapters_json)
Expand Down Expand Up @@ -294,9 +315,10 @@ def _detect_row_format_invalid_keywords(row_format: str, row_type: str = "Issue"
cleaned_row_format = row_format
for invalid_keyword in invalid_keywords:
logger.error(
"Invalid `{}` detected in `{}` row format keyword(s) found: {}. Will be removed from string.".format(
invalid_keyword, row_type, ", ".join(invalid_keywords)
)
"Invalid `%s` detected in `%s` row format keyword(s) found: %s. Will be removed from string.",
invalid_keyword,
row_type,
", ".join(invalid_keywords),
)
if clean:
cleaned_row_format = cleaned_row_format.replace(f"{{{invalid_keyword}}}", "")
Expand Down
1 change: 0 additions & 1 deletion release_notes_generator/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@
logger = logging.getLogger(__name__)


# TODO - reduce to function only after implementing the features. Will be supported more build ways?
# pylint: disable=too-few-public-methods
class ReleaseNotesBuilder:
"""
Expand Down
81 changes: 69 additions & 12 deletions release_notes_generator/generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,20 +20,23 @@
"""

import logging

import sys
from typing import Optional
import semver

from github import Github
from github.GitRelease import GitRelease
from github.Repository import Repository

from release_notes_generator.action_inputs import ActionInputs
from release_notes_generator.builder import ReleaseNotesBuilder
from release_notes_generator.model.custom_chapters import CustomChapters
from release_notes_generator.model.record import Record
from release_notes_generator.builder import ReleaseNotesBuilder
from release_notes_generator.record.record_factory import RecordFactory
from release_notes_generator.action_inputs import ActionInputs
from release_notes_generator.utils.constants import ISSUE_STATE_ALL

from release_notes_generator.utils.decorators import safe_call_decorator
from release_notes_generator.utils.utils import get_change_url
from release_notes_generator.utils.github_rate_limiter import GithubRateLimiter
from release_notes_generator.utils.utils import get_change_url

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -72,15 +75,13 @@ def generate(self) -> Optional[str]:

@return: The generated release notes as a string, or None if the repository could not be found.
"""
# get the repository
repo = self._safe_call(self.github_instance.get_repo)(ActionInputs.get_github_repository())
if repo is None:
return None

rls = self._safe_call(repo.get_latest_release)()
if rls is None:
logger.info("Latest release not found for %s. 1st release for repository!", repo.full_name)
else:
logger.debug("RLS created_at: %s, published_at: %s", rls.created_at, rls.published_at)
# get the latest release
rls = self.get_latest_release(repo)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Keep the type hints.


# default is repository creation date if no releases OR created_at of latest release
since = rls.created_at if rls else repo.created_at
Expand All @@ -97,12 +98,12 @@ def generate(self) -> Optional[str]:

# filter out closed Issues before the date
issues = list(
filter(lambda issue: issue.closed_at is not None and issue.closed_at > since, list(issues_all))
filter(lambda issue: issue.closed_at is not None and issue.closed_at >= since, list(issues_all))
)
logger.debug("Count of issues reduced from %d to %d", len(list(issues_all)), len(issues))

# filter out merged PRs and commits before the date
pulls = list(filter(lambda pull: pull.merged_at is not None and pull.merged_at > since, list(pulls_all)))
pulls = list(filter(lambda pull: pull.merged_at is not None and pull.merged_at >= since, list(pulls_all)))
logger.debug("Count of pulls reduced from %d to %d", len(list(pulls_all)), len(pulls))

commits = list(filter(lambda commit: commit.commit.author.date > since, list(commits_all)))
Expand All @@ -125,3 +126,59 @@ def generate(self) -> Optional[str]:
)

return release_notes_builder.build()

def get_latest_release(self, repo: Repository) -> Optional[GitRelease]:
"""
Get the latest release of the repository.

@param repo: The repository to get the latest release from.
@return: The latest release of the repository, or None if no releases are found.
"""
# check if from-tag name is defined
if ActionInputs.is_from_tag_name_defined():
logger.info("Getting latest release by from-tag name %s", ActionInputs.get_tag_name())
rls = self._safe_call(repo.get_release)(ActionInputs.get_from_tag_name())
Copy link
Collaborator

Choose a reason for hiding this comment

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

Keep.


if rls is None:
logger.info("Latest release not found for received tag %s. Ending!", ActionInputs.get_from_tag_name())
sys.exit(1)

else:
logger.info("Getting latest release by semantic ordering (could not be the last one by time).")
gh_releases: list = list(self._safe_call(repo.get_releases)())
rls: GitRelease = self.__get_latest_semantic_release(gh_releases)

if rls is None:
logger.info("Latest release not found for %s. 1st release for repository!", repo.full_name)

if rls is not None:
logger.debug(
"Latest release with tag:'%s' created_at: %s, published_at: %s",
rls.tag_name,
rls.created_at,
rls.published_at,
)

return rls

def __get_latest_semantic_release(self, releases) -> Optional[GitRelease]:
published_releases = [release for release in releases if not release.draft and not release.prerelease]
latest_version: Optional[semver.Version] = None
rls: Optional[GitRelease] = None

for release in published_releases:
try:
version_str = release.tag_name.lstrip("v")
current_version: Optional[semver.Version] = semver.VersionInfo.parse(version_str)
except ValueError:
logger.debug("Skipping invalid value of version tag: %s", release.tag_name)
continue
except TypeError:
logger.debug("Skipping invalid type of version tag: %s", release.tag_name)
continue

if latest_version is None or current_version > latest_version:
latest_version = current_version
rls = release

return rls
2 changes: 1 addition & 1 deletion release_notes_generator/record/record_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ def create_record_for_issue(r: Repository, i: Issue) -> None:
@return: None
"""
# check for skip labels presence and skip when detected
issue_labels = [label.name for label in issue.labels]
issue_labels = [label.name for label in i.labels]
skip_record = any(item in issue_labels for item in ActionInputs.get_skip_release_notes_labels())
records[i.number] = Record(r, i, skip=skip_record)

Expand Down
1 change: 1 addition & 0 deletions release_notes_generator/utils/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
GITHUB_REPOSITORY = "GITHUB_REPOSITORY"
GITHUB_TOKEN = "github-token"
TAG_NAME = "tag-name"
FROM_TAG_NAME = "from-tag-name"
CHAPTERS = "chapters"
DUPLICITY_SCOPE = "duplicity-scope"
DUPLICITY_ICON = "duplicity-icon"
Expand Down
4 changes: 4 additions & 0 deletions release_notes_generator/utils/pull_reuqest_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
# limitations under the License.
#

"""
This module contains the main script for the Release Notes Generator GH Action.
"""

import re

from github.PullRequest import PullRequest
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ PyGithub==1.59.0
pylint==3.2.6
requests==2.31.0
black==24.8.0
semver==3.0.2
16 changes: 15 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import pytest

from github import Github
from github.GitRelease import GitRelease
from github.Issue import Issue
from github.PullRequest import PullRequest
from github.Rate import Rate
Expand Down Expand Up @@ -83,11 +84,24 @@ def mock_repo(mocker):
# Fixtures for GitHub Release(s)
@pytest.fixture
def mock_git_release(mocker):
release = mocker.Mock()
release = mocker.Mock(spec=GitRelease)
release.tag_name = "v1.0.0"
return release


@pytest.fixture
def mock_git_releases(mocker):
release_1 = mocker.Mock(spec=GitRelease)
release_1.tag_name = "v1.0.0"
release_1.draft = False
release_1.prerelease = False
release_2 = mocker.Mock(spec=GitRelease)
release_2.tag_name = "v2.0.0"
release_2.draft = False
release_2.prerelease = False
return [release_1, release_2]


@pytest.fixture
def rate_limiter(mocker, request):
mock_github_client = mocker.Mock(spec=Github)
Expand Down
Loading
Loading