Skip to content

Commit 2b1453f

Browse files
srevinsajuTheAssassin
authored andcommitted
feat: add changelog / release notes support
pyuploadtool now generates changelog for GitHub releases which is opt-in. Changelogs will be generated only when GENERATE_CHANGELOG=true feat: prepend the metadata.description with the generated changelog fix: rename get_changelog to render_to_markdown its a more intuitive function name and clearly explains that the output is a string of markdown data fix: do not replace the existing metadata description, but append to it feat: expose get_changelog this function can in be future be replaced by a a changelog handling object feat: add support for restrictive conventional commit spec fix: remove redundant comment style: add more blank lines feat: restructure changelog generator style: format with black fix: circular imports on changelog docs: update documentation to show CHANGELOG support refactor: complete refactor from Parser to ChangelogParser fix: refactor to use attributes for Commit object instead of dict.get fix: for github releases, set the commit_prefix_link style: lint with black style: move ReleaseUpdate below .. imports (pep8) fix: convert Changelog.structure to staticmethod feat: use NamedTuple instead of complicating the implementation style: remove redundant _ prefixes to local variables style: remove redundant line feat: do not edit metadata in the release provider fix: docstrings for Changelog.changelog, Changelog.structure fix: use type annotations instead of type in docstrings refactor: ChangelogCommit to ChangelogEntry to make it more general fix: allow providing lowercase values for CHANGELOG_TYPE env variable feat: remove the need to specify CHANGELOG_GENERATE environment variable. Automatically generate changelog if CHANGELOG_TYPE is defined to 'standard' or 'conventional' docs: improve docstrings of MarkdownChangelogParser.render_to_markdown docs: improve docstrings of Changelog.structure Add support for scheduled and manual builds Print non-string types properly Improve logging Convert metadata to correct types Fix comparison (and show intention more clearly) Check code format with black Run checks on PRs as well Use poetry to manage dependencies in CI Debug dependencies installatino Forgot to check out the code Format YAML properly Add incomplete list of projects using pyuploadtool Pin poetry version Workaround for python-poetry/poetry#3153. Fix type issue When calling sanitize on an int, e.g., the pipeline run number, it might fail as the passed type is not iterable. This little fix makes sure whatever is passed is interpreted as a string. fix: remove redundant imports fix: do not attempt to generate changelog if the previous tag is missing fix: changelog.structure is not a property fix: ChangelogEntry should not be a tuple, because it needs to be edited runtime
1 parent e56aab1 commit 2b1453f

File tree

16 files changed

+402
-3
lines changed

16 files changed

+402
-3
lines changed

README.md

+15
Original file line numberDiff line numberDiff line change
@@ -92,3 +92,18 @@ You can upload to any WebDAV server which supports `PUT` operations. The followi
9292
- `$WEBDAV_RELEASE_NAME`: name of the release directory (optional on *GitHub actions*)
9393

9494
**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).
95+
96+
97+
## Changelog Generation
98+
`pyuploadtool` support Changelog generation, which is optional, and can be enabled with the `CHANGELOG_TYPE` environment variable.
99+
```bash
100+
CHANGELOG_TYPE=standard ./pyuploadtool*.AppImage
101+
```
102+
103+
### Changelog Types
104+
`CHANGELOG_TYPE` can have any of the following values:
105+
* `CHANGELOG_TYPE=none`, to disable generating Changelog (default)
106+
* `CHANGELOG_TYPE=standard`, Standard Changelog
107+
* `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.
108+
109+
By default, `CHANGELOG_TYPE` is `none` unless explicitly specified.

pyuploadtool/build_systems/github_actions.py

-1
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,6 @@ def update_release_metadata(self, metadata: ReleaseMetadata):
5858
# the create event can occur whenever a tag or branch is created
5959
if event_name == "pull_request":
6060
metadata.build_type = BuildType.PULL_REQUEST
61-
6261
elif event_name == "push":
6362
if metadata.tag:
6463
metadata.build_type = BuildType.TAG

pyuploadtool/changelog/__init__.py

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from .changelog import Changelog
2+
from .types import ChangelogType
3+
from .changelog_spec import ConventionalCommitChangelog
4+
5+
6+
__all__ = (Changelog, ConventionalCommitChangelog, ChangelogType)

pyuploadtool/changelog/author.py

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
class Author:
2+
def __init__(
3+
self,
4+
name: str = None,
5+
email: str = None,
6+
):
7+
self._name = name
8+
self._email = email
9+
10+
@property
11+
def name(self):
12+
return self._name
13+
14+
@property
15+
def email(self):
16+
return self._email

pyuploadtool/changelog/changelog.py

+39
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from .commit import ChangelogEntry
2+
3+
4+
class Changelog:
5+
def __init__(self):
6+
self._data = dict()
7+
for spec in self.structure():
8+
self._data[spec] = list()
9+
10+
def __repr__(self):
11+
print(f"{self.__name__}({self._data})")
12+
13+
def __iter__(self):
14+
return iter(self._data)
15+
16+
def __getitem__(self, item):
17+
return self._data[item]
18+
19+
@staticmethod
20+
def structure() -> dict:
21+
"""
22+
Returns a dictionary with a minimal structure of a changelog.
23+
All commits would be classified as others by default.
24+
:return: A dictionary with keys and their descriptive
25+
names which would be used for creating headings
26+
"""
27+
return {"others": "Commits"}
28+
29+
def push(self, commit: ChangelogEntry) -> str:
30+
"""
31+
Adds a commit to the changelog
32+
:return: The classification of the commit = other
33+
"""
34+
self._data["others"].append(commit)
35+
return "others"
36+
37+
@property
38+
def changelog(self) -> dict:
39+
return self._data
+55
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import re
2+
3+
from .changelog import Changelog
4+
from .commit import ChangelogEntry
5+
6+
7+
class ConventionalCommitChangelog(Changelog):
8+
@staticmethod
9+
def structure() -> dict:
10+
"""
11+
Returns a structure of the Conventional Commit Spec
12+
according to https://cheatography.com/albelop/cheat-sheets/conventional-commits/
13+
14+
The order of the commits in the dictionary is according to the
15+
priority
16+
:return:
17+
:rtype:
18+
"""
19+
return {
20+
"feat": "Features",
21+
"fix": "Bug Fixes",
22+
"perf": "Performance Improvements",
23+
"docs": "Documentation",
24+
"ci": "Continuous Integration",
25+
"refactor": "Refactoring",
26+
"test": "Tests",
27+
"build": "Builds",
28+
"revert": "Reverts",
29+
"chore": "Chores",
30+
"others": "Commits",
31+
}
32+
33+
def push(self, commit: ChangelogEntry) -> str:
34+
"""
35+
Adds a commit to the changelog and aligns each commit
36+
based on their category. See self.structure
37+
:param commit
38+
:type commit: ChangelogEntry
39+
:return: The classification of the commit == self.structure.keys()
40+
:rtype: str
41+
"""
42+
43+
for spec in self.structure():
44+
if commit.message.startswith(f"{spec}:"):
45+
commit.message = commit.message[len(f"{spec}:") + 1 :].strip()
46+
self._data[spec].append(commit)
47+
return spec
48+
elif re.search(f"{spec}.*(.*):.*", commit.message):
49+
commit.message = commit.message[commit.message.find(":") + 1 :].strip()
50+
self._data[spec].append(commit)
51+
return spec
52+
53+
# it did not fit into any proper category, lets push to others
54+
self._data["others"].append(commit)
55+
return "others"

pyuploadtool/changelog/commit.py

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from typing import NamedTuple
2+
3+
from github.Commit import Commit
4+
5+
from .author import Author
6+
7+
8+
class ChangelogEntry:
9+
def __init__(self, author: Author, message: str, sha: str):
10+
self.author = author
11+
self.message = message
12+
self.sha = sha
13+
14+
@classmethod
15+
def from_github_commit(cls, commit: Commit):
16+
"""
17+
Converts a github commit to a pyuploadtool compatible
18+
ChangelogEntry instance
19+
"""
20+
author = Author(name=commit.author.name, email=commit.author.email)
21+
message = commit.commit.message
22+
sha = commit.sha
23+
return ChangelogEntry(author=author, message=message, sha=sha)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from .base import ChangelogFactory
2+
from .github import GitHubChangelogFactory
3+
4+
__all__ = (ChangelogFactory, GitHubChangelogFactory)
+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from typing import Type
2+
3+
from .. import ChangelogType, Changelog, ConventionalCommitChangelog
4+
5+
6+
SUPPORTED_CHANGELOG_TYPES = {ChangelogType.STANDARD: Changelog, ChangelogType.CONVENTIONAL: ConventionalCommitChangelog}
7+
8+
9+
class ChangelogTypeNotImplemented(NotImplementedError):
10+
pass
11+
12+
13+
class ChangelogFactory:
14+
def __init__(self, changelog_type: ChangelogType = None):
15+
self.changelog_type = changelog_type
16+
self.changelog_generator = self.get_changelog_generator()
17+
18+
def get_changelog_generator(self) -> Type[Changelog]:
19+
"""
20+
Get the corresponding changelog generator from the environment
21+
if it is not supplied.
22+
:return:
23+
:rtype: ChangelogType
24+
"""
25+
if self.changelog_type is None:
26+
self.changelog_type = ChangelogType.from_environment()
27+
28+
generator = SUPPORTED_CHANGELOG_TYPES.get(self.changelog_type)
29+
if generator is None:
30+
raise ChangelogTypeNotImplemented(f"{self.changelog_type} is not a supported ChangeLogType")
31+
32+
return generator
+105
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import github
2+
3+
from typing import Optional
4+
from github import Github
5+
from github.GitRelease import GitRelease
6+
7+
from .. import Changelog
8+
from .base import ChangelogFactory
9+
from ..commit import ChangelogEntry
10+
from ...metadata import ReleaseMetadata
11+
from ...logging import make_logger
12+
13+
14+
class GitHubChangelogFactory(ChangelogFactory):
15+
logger = make_logger("github-changelog-generator")
16+
17+
def __init__(self, github_client: Github, metadata: ReleaseMetadata):
18+
"""
19+
Prepares the changelog using GitHub REST API by
20+
comparing the current commit against the latest release (pre-release / stable)
21+
"""
22+
super().__init__()
23+
self.metadata = metadata
24+
self.github_client = github_client
25+
self.repository = github_client.get_repo(metadata.repository_slug)
26+
27+
def get_latest_release(self):
28+
"""
29+
Gets the latest release by semver, like v8.0.1, v4.5.9, if not
30+
Fallback to continuous releases, like 'continuous', 'stable', 'nightly'
31+
32+
:return: the tag name of the latest release, and the date on which it was created
33+
:rtype: GitRelease
34+
"""
35+
36+
releases = self.repository.get_releases()
37+
latest_release = None
38+
rolling_release = None
39+
for release in releases:
40+
if not release.tag_name.startswith("v") or not release.tag_name[0].isdigit():
41+
# the release does not follow semver specs
42+
43+
if rolling_release is None or (rolling_release and release.created_at > rolling_release.created_at):
44+
# probably, we are looking at a rolling release
45+
# like 'continuous', 'beta', etc..
46+
rolling_release = release
47+
48+
elif latest_release is None:
49+
# we still dont have a latest release,
50+
# so we need to set whatever release we currently are at
51+
# as the latest release
52+
latest_release = release
53+
54+
elif release.created_at > latest_release.created_at:
55+
# we found a release for which, the current release is newer
56+
# than the stored one
57+
latest_release = release
58+
59+
# we found a release which does not follow
60+
# semver specs, and it is a probably a rolling release
61+
# just provide that as the latest release
62+
# so we need to return that, if we didnt find a suitable latest_release
63+
return latest_release or rolling_release
64+
65+
def get_commits_since(self, tag) -> Optional[github.Comparison.Comparison]:
66+
"""
67+
Gets all the commits since a tag to self.commit_sha
68+
:return
69+
"""
70+
try:
71+
commits = self.repository.compare(tag, self.metadata.commit).commits
72+
except Exception as e:
73+
self.logger.warn(
74+
f"Failed to compared across {tag} and " f"{self.metadata.commit}: {e}. " f"Not generating changelog."
75+
)
76+
return list()
77+
return commits
78+
79+
def get_changelog(self):
80+
"""
81+
Wrapper command to generate the changelog
82+
:return: markdown data as changelog
83+
:rtype: Changelog
84+
"""
85+
86+
latest_release = self.get_latest_release()
87+
88+
if latest_release is None:
89+
# We couldn't find out the latest release. Lets stick with
90+
# the commit above the commit we are working against.
91+
92+
# FIXME: Looks like it works fine... Need some tests here
93+
latest_release = f"{self.metadata.commit}^1"
94+
else:
95+
latest_release = latest_release.tag_name
96+
97+
commits = self.get_commits_since(latest_release)
98+
self.logger.debug(f"Found {len(commits)} commits")
99+
100+
changelog = self.changelog_generator()
101+
102+
for commit in commits:
103+
changelog.push(ChangelogEntry.from_github_commit(commit))
104+
105+
return changelog
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from .parser import ChangelogParser
2+
from .markdown import MarkdownChangelogParser
3+
4+
5+
__all__ = (ChangelogParser, MarkdownChangelogParser)
+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from .parser import ChangelogParser
2+
3+
4+
class MarkdownChangelogParser(ChangelogParser):
5+
def render_to_markdown(self) -> str:
6+
"""
7+
Parses the changelog to Markdown format
8+
:return: a string containing parsed markdown information
9+
"""
10+
markdown_changelog = list()
11+
# add the title if it is provided
12+
if self.title is not None:
13+
markdown_changelog.append(f"# {self.title}")
14+
15+
for spec in self.changelog.structure():
16+
17+
if len(self.changelog[spec]) > 0:
18+
# append a new line before then next section
19+
markdown_changelog.append("\n")
20+
markdown_changelog.append(f"## {self.changelog.structure().get(spec)}")
21+
22+
for commit in self.changelog[spec]:
23+
if self.commit_link_prefix:
24+
author = f"([{commit.author.name}]({self.commit_link_prefix}/{commit.sha}))"
25+
else:
26+
author = f"({commit.author.name})"
27+
28+
markdown_changelog.append(f"* {commit.message} {author}")
29+
30+
return "\n".join(markdown_changelog)
+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from .. import Changelog
2+
3+
4+
class ChangelogParser:
5+
def __init__(
6+
self,
7+
changelog: Changelog,
8+
title: str = None,
9+
commit_link_prefix: str = None,
10+
):
11+
"""
12+
Generates a changelog by arranging the commits according
13+
to the Conventional Commit Spec
14+
15+
:param title: the title of the release, generally, the tag name
16+
:type title: str
17+
18+
:param commit_link_prefix: a link prefix, which can be used to show a commit
19+
for example
20+
commit_link_prefix = https://github.com/$GITHUB_REPOSITORY/commit
21+
here, we will add the commit hash to the end.
22+
:type commit_link_prefix: str
23+
"""
24+
self.changelog = changelog
25+
self.commit_link_prefix = commit_link_prefix.rstrip("/")
26+
self.title = title

0 commit comments

Comments
 (0)