diff --git a/README.md b/README.md index ddf2311..e0c8d5d 100644 --- a/README.md +++ b/README.md @@ -85,3 +85,18 @@ You can upload to any WebDAV server which supports `PUT` operations. The followi - `$WEBDAV_RELEASE_NAME`: name of the release directory (optional on *GitHub actions*) **Note:** Secrets must not be stored inside the repository, nor be visible to end users. You need to store them securely, ideally using the credentials storage your build system provides (on GitHub actions, there's *Secrets*, for instance). + + +## Changelog Generation +`pyuploadtool` support Changelog generation, which is optional, and can be enabled with the `CHANGELOG_TYPE` environment variable. +```bash +CHANGELOG_TYPE=standard ./pyuploadtool*.AppImage +``` + +### Changelog Types +`CHANGELOG_TYPE` can have any of the following values: +* `CHANGELOG_TYPE=none`, to disable generating Changelog (default) +* `CHANGELOG_TYPE=standard`, Standard Changelog +* `CHANGELOG_TYPE=conventional`, Conventional changelog, follows the [Conventional Commit Spec](https://www.conventionalcommits.org/) which classifies your commits as Features, Bug Fixes, etc, provided your commits follow the spec. + +By default, `CHANGELOG_TYPE` is `none` unless explicitly specified. diff --git a/pyuploadtool/build_systems/github_actions.py b/pyuploadtool/build_systems/github_actions.py index 47c3be5..eb90b3e 100644 --- a/pyuploadtool/build_systems/github_actions.py +++ b/pyuploadtool/build_systems/github_actions.py @@ -58,7 +58,6 @@ def update_release_metadata(self, metadata: ReleaseMetadata): # the create event can occur whenever a tag or branch is created if event_name == "pull_request": metadata.build_type = BuildType.PULL_REQUEST - elif event_name == "push": if metadata.tag: metadata.build_type = BuildType.TAG diff --git a/pyuploadtool/changelog/__init__.py b/pyuploadtool/changelog/__init__.py new file mode 100644 index 0000000..d41ef55 --- /dev/null +++ b/pyuploadtool/changelog/__init__.py @@ -0,0 +1,6 @@ +from .changelog import Changelog +from .types import ChangelogType +from .changelog_spec import ConventionalCommitChangelog + + +__all__ = (Changelog, ConventionalCommitChangelog, ChangelogType) diff --git a/pyuploadtool/changelog/author.py b/pyuploadtool/changelog/author.py new file mode 100644 index 0000000..e42c723 --- /dev/null +++ b/pyuploadtool/changelog/author.py @@ -0,0 +1,16 @@ +class Author: + def __init__( + self, + name: str = None, + email: str = None, + ): + self._name = name + self._email = email + + @property + def name(self): + return self._name + + @property + def email(self): + return self._email diff --git a/pyuploadtool/changelog/changelog.py b/pyuploadtool/changelog/changelog.py new file mode 100644 index 0000000..ae85f9e --- /dev/null +++ b/pyuploadtool/changelog/changelog.py @@ -0,0 +1,39 @@ +from .commit import ChangelogEntry + + +class Changelog: + def __init__(self): + self._data = dict() + for spec in self.structure(): + self._data[spec] = list() + + def __repr__(self): + print(f"{self.__name__}({self._data})") + + def __iter__(self): + return iter(self._data) + + def __getitem__(self, item): + return self._data[item] + + @staticmethod + def structure() -> dict: + """ + Returns a dictionary with a minimal structure of a changelog. + All commits would be classified as others by default. + :return: A dictionary with keys and their descriptive + names which would be used for creating headings + """ + return {"others": "Commits"} + + def push(self, commit: ChangelogEntry) -> str: + """ + Adds a commit to the changelog + :return: The classification of the commit = other + """ + self._data["others"].append(commit) + return "others" + + @property + def changelog(self) -> dict: + return self._data diff --git a/pyuploadtool/changelog/changelog_spec.py b/pyuploadtool/changelog/changelog_spec.py new file mode 100644 index 0000000..7fd97a4 --- /dev/null +++ b/pyuploadtool/changelog/changelog_spec.py @@ -0,0 +1,55 @@ +import re + +from .changelog import Changelog +from .commit import ChangelogEntry + + +class ConventionalCommitChangelog(Changelog): + @staticmethod + def structure() -> dict: + """ + Returns a structure of the Conventional Commit Spec + according to https://cheatography.com/albelop/cheat-sheets/conventional-commits/ + + The order of the commits in the dictionary is according to the + priority + :return: + :rtype: + """ + return { + "feat": "Features", + "fix": "Bug Fixes", + "perf": "Performance Improvements", + "docs": "Documentation", + "ci": "Continuous Integration", + "refactor": "Refactoring", + "test": "Tests", + "build": "Builds", + "revert": "Reverts", + "chore": "Chores", + "others": "Commits", + } + + def push(self, commit: ChangelogEntry) -> str: + """ + Adds a commit to the changelog and aligns each commit + based on their category. See self.structure + :param commit + :type commit: ChangelogEntry + :return: The classification of the commit == self.structure.keys() + :rtype: str + """ + + for spec in self.structure(): + if commit.message.startswith(f"{spec}:"): + commit.message = commit.message[len(f"{spec}:") + 1 :].strip() + self._data[spec].append(commit) + return spec + elif re.search(f"{spec}.*(.*):.*", commit.message): + commit.message = commit.message[commit.message.find(":") + 1 :].strip() + self._data[spec].append(commit) + return spec + + # it did not fit into any proper category, lets push to others + self._data["others"].append(commit) + return "others" diff --git a/pyuploadtool/changelog/commit.py b/pyuploadtool/changelog/commit.py new file mode 100644 index 0000000..0c4f098 --- /dev/null +++ b/pyuploadtool/changelog/commit.py @@ -0,0 +1,23 @@ +from typing import NamedTuple + +from github.Commit import Commit + +from .author import Author + + +class ChangelogEntry: + def __init__(self, author: Author, message: str, sha: str): + self.author = author + self.message = message + self.sha = sha + + @classmethod + def from_github_commit(cls, commit: Commit): + """ + Converts a github commit to a pyuploadtool compatible + ChangelogEntry instance + """ + author = Author(name=commit.author.name, email=commit.author.email) + message = commit.commit.message + sha = commit.sha + return ChangelogEntry(author=author, message=message, sha=sha) diff --git a/pyuploadtool/changelog/factory/__init__.py b/pyuploadtool/changelog/factory/__init__.py new file mode 100644 index 0000000..ddd3480 --- /dev/null +++ b/pyuploadtool/changelog/factory/__init__.py @@ -0,0 +1,4 @@ +from .base import ChangelogFactory +from .github import GitHubChangelogFactory + +__all__ = (ChangelogFactory, GitHubChangelogFactory) diff --git a/pyuploadtool/changelog/factory/base.py b/pyuploadtool/changelog/factory/base.py new file mode 100644 index 0000000..f8cb5e1 --- /dev/null +++ b/pyuploadtool/changelog/factory/base.py @@ -0,0 +1,32 @@ +from typing import Type + +from .. import ChangelogType, Changelog, ConventionalCommitChangelog + + +SUPPORTED_CHANGELOG_TYPES = {ChangelogType.STANDARD: Changelog, ChangelogType.CONVENTIONAL: ConventionalCommitChangelog} + + +class ChangelogTypeNotImplemented(NotImplementedError): + pass + + +class ChangelogFactory: + def __init__(self, changelog_type: ChangelogType = None): + self.changelog_type = changelog_type + self.changelog_generator = self.get_changelog_generator() + + def get_changelog_generator(self) -> Type[Changelog]: + """ + Get the corresponding changelog generator from the environment + if it is not supplied. + :return: + :rtype: ChangelogType + """ + if self.changelog_type is None: + self.changelog_type = ChangelogType.from_environment() + + generator = SUPPORTED_CHANGELOG_TYPES.get(self.changelog_type) + if generator is None: + raise ChangelogTypeNotImplemented(f"{self.changelog_type} is not a supported ChangeLogType") + + return generator diff --git a/pyuploadtool/changelog/factory/github.py b/pyuploadtool/changelog/factory/github.py new file mode 100644 index 0000000..37b675b --- /dev/null +++ b/pyuploadtool/changelog/factory/github.py @@ -0,0 +1,105 @@ +import github + +from typing import Optional +from github import Github +from github.GitRelease import GitRelease + +from .. import Changelog +from .base import ChangelogFactory +from ..commit import ChangelogEntry +from ...metadata import ReleaseMetadata +from ...logging import make_logger + + +class GitHubChangelogFactory(ChangelogFactory): + logger = make_logger("github-changelog-generator") + + def __init__(self, github_client: Github, metadata: ReleaseMetadata): + """ + Prepares the changelog using GitHub REST API by + comparing the current commit against the latest release (pre-release / stable) + """ + super().__init__() + self.metadata = metadata + self.github_client = github_client + self.repository = github_client.get_repo(metadata.repository_slug) + + def get_latest_release(self): + """ + Gets the latest release by semver, like v8.0.1, v4.5.9, if not + Fallback to continuous releases, like 'continuous', 'stable', 'nightly' + + :return: the tag name of the latest release, and the date on which it was created + :rtype: GitRelease + """ + + releases = self.repository.get_releases() + latest_release = None + rolling_release = None + for release in releases: + if not release.tag_name.startswith("v") or not release.tag_name[0].isdigit(): + # the release does not follow semver specs + + if rolling_release is None or (rolling_release and release.created_at > rolling_release.created_at): + # probably, we are looking at a rolling release + # like 'continuous', 'beta', etc.. + rolling_release = release + + elif latest_release is None: + # we still dont have a latest release, + # so we need to set whatever release we currently are at + # as the latest release + latest_release = release + + elif release.created_at > latest_release.created_at: + # we found a release for which, the current release is newer + # than the stored one + latest_release = release + + # we found a release which does not follow + # semver specs, and it is a probably a rolling release + # just provide that as the latest release + # so we need to return that, if we didnt find a suitable latest_release + return latest_release or rolling_release + + def get_commits_since(self, tag) -> Optional[github.Comparison.Comparison]: + """ + Gets all the commits since a tag to self.commit_sha + :return + """ + try: + commits = self.repository.compare(tag, self.metadata.commit).commits + except Exception as e: + self.logger.warn( + f"Failed to compared across {tag} and " f"{self.metadata.commit}: {e}. " f"Not generating changelog." + ) + return list() + return commits + + def get_changelog(self): + """ + Wrapper command to generate the changelog + :return: markdown data as changelog + :rtype: Changelog + """ + + latest_release = self.get_latest_release() + + if latest_release is None: + # We couldn't find out the latest release. Lets stick with + # the commit above the commit we are working against. + + # FIXME: Looks like it works fine... Need some tests here + latest_release = f"{self.metadata.commit}^1" + else: + latest_release = latest_release.tag_name + + commits = self.get_commits_since(latest_release) + self.logger.debug(f"Found {len(commits)} commits") + + changelog = self.changelog_generator() + + for commit in commits: + changelog.push(ChangelogEntry.from_github_commit(commit)) + + return changelog diff --git a/pyuploadtool/changelog/parsers/__init__.py b/pyuploadtool/changelog/parsers/__init__.py new file mode 100644 index 0000000..aba9be7 --- /dev/null +++ b/pyuploadtool/changelog/parsers/__init__.py @@ -0,0 +1,5 @@ +from .parser import ChangelogParser +from .markdown import MarkdownChangelogParser + + +__all__ = (ChangelogParser, MarkdownChangelogParser) diff --git a/pyuploadtool/changelog/parsers/markdown.py b/pyuploadtool/changelog/parsers/markdown.py new file mode 100644 index 0000000..75c9547 --- /dev/null +++ b/pyuploadtool/changelog/parsers/markdown.py @@ -0,0 +1,30 @@ +from .parser import ChangelogParser + + +class MarkdownChangelogParser(ChangelogParser): + def render_to_markdown(self) -> str: + """ + Parses the changelog to Markdown format + :return: a string containing parsed markdown information + """ + markdown_changelog = list() + # add the title if it is provided + if self.title is not None: + markdown_changelog.append(f"# {self.title}") + + for spec in self.changelog.structure(): + + if len(self.changelog[spec]) > 0: + # append a new line before then next section + markdown_changelog.append("\n") + markdown_changelog.append(f"## {self.changelog.structure().get(spec)}") + + for commit in self.changelog[spec]: + if self.commit_link_prefix: + author = f"([{commit.author.name}]({self.commit_link_prefix}/{commit.sha}))" + else: + author = f"({commit.author.name})" + + markdown_changelog.append(f"* {commit.message} {author}") + + return "\n".join(markdown_changelog) diff --git a/pyuploadtool/changelog/parsers/parser.py b/pyuploadtool/changelog/parsers/parser.py new file mode 100644 index 0000000..70fadc4 --- /dev/null +++ b/pyuploadtool/changelog/parsers/parser.py @@ -0,0 +1,26 @@ +from .. import Changelog + + +class ChangelogParser: + def __init__( + self, + changelog: Changelog, + title: str = None, + commit_link_prefix: str = None, + ): + """ + Generates a changelog by arranging the commits according + to the Conventional Commit Spec + + :param title: the title of the release, generally, the tag name + :type title: str + + :param commit_link_prefix: a link prefix, which can be used to show a commit + for example + commit_link_prefix = https://github.com/$GITHUB_REPOSITORY/commit + here, we will add the commit hash to the end. + :type commit_link_prefix: str + """ + self.changelog = changelog + self.commit_link_prefix = commit_link_prefix.rstrip("/") + self.title = title diff --git a/pyuploadtool/changelog/types.py b/pyuploadtool/changelog/types.py new file mode 100644 index 0000000..2b05d51 --- /dev/null +++ b/pyuploadtool/changelog/types.py @@ -0,0 +1,26 @@ +import os +from enum import Enum + + +class ChangelogType(Enum): + # none + NONE = -1 + + # default + STANDARD = 0 + + # follows the Conventional Commit Spec + CONVENTIONAL = 1 + + @staticmethod + def from_environment(): + type = os.getenv("CHANGELOG_TYPE") + if type is None: + return ChangelogType.STANDARD + + for i in ChangelogType: + if type.isdigit() and int(type) == i.value or type.lower() == i.name.lower(): + return i + + # fall back to the default + return ChangelogType.NONE diff --git a/pyuploadtool/metadata.py b/pyuploadtool/metadata.py index 969f8f6..ec4e5c0 100644 --- a/pyuploadtool/metadata.py +++ b/pyuploadtool/metadata.py @@ -1,6 +1,7 @@ import os -from pyuploadtool import BuildType +from . import BuildType +from .changelog import Changelog class ReleaseMetadata: @@ -26,6 +27,7 @@ def __init__( pipeline_name: str = None, pipeline_run_number: str = None, build_type: BuildType = None, + changelog: Changelog = None, ): # name of the current tag self.tag = tag @@ -65,6 +67,9 @@ def __init__( build_type = BuildType.UNKNOWN self.build_type = build_type + # changelog + self.changelog = changelog + def __repr__(self): args = ", ".join( ( diff --git a/pyuploadtool/releases_hosting_provider/github_releases.py b/pyuploadtool/releases_hosting_provider/github_releases.py index 8f7c53b..80726e6 100644 --- a/pyuploadtool/releases_hosting_provider/github_releases.py +++ b/pyuploadtool/releases_hosting_provider/github_releases.py @@ -5,6 +5,9 @@ from . import ReleaseHostingProviderError from .base import ReleasesHostingProviderBase from .. import ReleaseMetadata, BuildType +from ..changelog import ChangelogType +from ..changelog.parsers import MarkdownChangelogParser +from ..changelog.factory.github import GitHubChangelogFactory from ..logging import make_logger @@ -91,7 +94,17 @@ def create_release(self, metadata: ReleaseMetadata, artifacts): message = f"Build log: {metadata.build_log_url}" - if metadata.release_description is not None: + should_generate_changelog = ChangelogType.from_environment() != ChangelogType.NONE + + if should_generate_changelog and metadata.release_description is None: + github_changelog = GitHubChangelogFactory(github_client=self.github_client, metadata=metadata) + changelog = github_changelog.get_changelog() + markdown_changelog = MarkdownChangelogParser( + changelog, commit_link_prefix=f"https://github.com/{metadata.repository_slug}/commit/" + ).render_to_markdown() + message = f"{markdown_changelog}\n\n{message}" + + elif metadata.release_description is not None: message = f"{metadata.release_description}\n\n{message}" # for some annoying reason, you have to specify all the metadata both when drafting _and_ creating the release