diff --git a/.gitignore b/.gitignore index 74aa0ba..222ecbd 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,10 @@ Pipfile.lock example/ example2/ -output.md \ No newline at end of file +example3/ +output.md +__pycache__/ +src/dist/ +build/ +dist/ +*.egg-info/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..2e27863 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,27 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) +and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). + +## [0.1.0](https://gitlab.com/pawamoy/gitolog/tags/0.1.0) ([compare](https://gitlab.com/pawamoy/gitolog/compare/83845fe8d7deb85a2e093fe68a4b6a48b6d8e446...0.1.0)) - 2018-06-27 + +### Added +- Add github/gitlab regexes ([584fd73](https://gitlab.com/pawamoy/gitolog/commit/584fd73ec88ac51abbf8555d8f78b7144529e6b3)). + +### Fixed +- Fix patch bump ([8470e69](https://gitlab.com/pawamoy/gitolog/commit/8470e695128d9892296acdd31c404d85add68983)). +- Fix refs parsing ([8c77cb7](https://gitlab.com/pawamoy/gitolog/commit/8c77cb736971473837384a8238c3c53886d77c75)). + +### Misc +- Continue packaging (#6) ([a29af2c](https://gitlab.com/pawamoy/gitolog/commit/a29af2cf990edf950b55a46ebea164ab068c9aec)). +- Finish packaging (#6) ([e92b492](https://gitlab.com/pawamoy/gitolog/commit/e92b4923a60d561c38150331dac9cd2e3ba6c130)). +- Implement reference parsing ([a9b4a89](https://gitlab.com/pawamoy/gitolog/commit/a9b4a89cd2737056166feb7a46da971549f1ffed)). +- Improve angular template, improve style/refs system ([5b87d48](https://gitlab.com/pawamoy/gitolog/commit/5b87d48acdf3aa0f5cc2731f48e372c4065d9f9b)). +- Initial commit ([83845fe](https://gitlab.com/pawamoy/gitolog/commit/83845fe8d7deb85a2e093fe68a4b6a48b6d8e446)). +- Package code (#6) ([1219eaf](https://gitlab.com/pawamoy/gitolog/commit/1219eafd02521f6f6ab942a02b7a7aee3d664143)). +- Update changelog for version 0.1.0 ([610633d](https://gitlab.com/pawamoy/gitolog/commit/610633da8a569e7f2966f1675a30aca651563e0b)). +- Update changelog for version 0.1.0 ([2eaaa2e](https://gitlab.com/pawamoy/gitolog/commit/2eaaa2e76fc35d111517ecd0a15daf65e705723c)). +- Work in progress ([27a60e8](https://gitlab.com/pawamoy/gitolog/commit/27a60e80e9a8308b88942311184346b1bfa4b0a8)). + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..a69b8f9 --- /dev/null +++ b/README.md @@ -0,0 +1,69 @@ +# gitolog +Automatic changelog generator. From git logs to change logs. + +- Installation: `sudo pip3 install gitolog` +- Features: + - [Jinja2][jinja2] templates! + You get full control over the rendering. + Built-in [Keep a Changelog][keep-a-changelog] and [Angular][angular] templates + (also see [Conventional Changelog][conventional-changelog]). + - Commit styles/conventions parsing. + Built-in [Angular][angular-style], [Atom][atom-style] and basic styles. + - Git service/provider agnostic, + plus references parsing (issues, commits, etc.). + Built-in [GitHub][github-refs] and [Gitlab][gitlab-refs] support. + - Understands [Semantic Versioning][semantic-versioning]: + major/minor/patch for versions and commits. + Guesses next version based on last commits. +- Todo: + - [Plugin architecture][issue-7], + to support more commit styles and git services. + - [Template context injection][issue-4], + to furthermore customize how your changelog will be rendered. + - [Easy access to "Breaking Changes"][issue-1] in the templates. + - [Update changelog in-place][issue-2], paired with + [commits/dates/versions range limitation ability][issue-3]. + +## Command-line + +```console +$ gitolog --help +usage: gitolog [-h] [-o OUTPUT] [-s {angular,atom,basic}] + [-t {angular,keepachangelog}] [-v] + REPOSITORY + +Command line tool for gitolog Python package. + +positional arguments: + REPOSITORY The repository path, relative or absolute. + +optional arguments: + -h, --help Show this help message and exit. + -o OUTPUT, --output OUTPUT + Output to given file. Default: stdout. + -s {angular,atom,basic}, --style {angular,atom,basic} + The commit style to match against. + -t {angular,keepachangelog}, --template {angular,keepachangelog} + The Jinja2 template to use. Prefix with "path:" to + specify the path to a directory containing a file + named "changelog.md". + -v, --version Show the current version of the program and exit. +``` + +[jinja2]: http://jinja.pocoo.org/ +[keep-a-changelog]: http://keepachangelog.com/en/1.0.0/ +[angular]: https://github.com/angular/angular/blob/master/CHANGELOG.md +[conventional-changelog]: https://github.com/conventional-changelog/conventional-changelog +[semantic-versioning]: http://semver.org/spec/v2.0.0.html +[atom-style]: https://github.com/atom/atom/blob/master/CONTRIBUTING.md#git-commit-messages +[angular-style]: https://github.com/angular/angular/blob/master/CONTRIBUTING.md#commit +[github-refs]: https://help.github.com/articles/autolinked-references-and-urls/ +[gitlab-refs]: https://docs.gitlab.com/ce/user/markdown.html#special-gitlab-references + +[issue-1]: https://gitlab.com/pawamoy/gitolog/issues/1 +[issue-2]: https://gitlab.com/pawamoy/gitolog/issues/2 +[issue-3]: https://gitlab.com/pawamoy/gitolog/issues/3 +[issue-4]: https://gitlab.com/pawamoy/gitolog/issues/4 +[issue-5]: https://gitlab.com/pawamoy/gitolog/issues/5 +[issue-6]: https://gitlab.com/pawamoy/gitolog/issues/6 +[issue-7]: https://gitlab.com/pawamoy/gitolog/issues/7 \ No newline at end of file diff --git a/script.py b/script.py deleted file mode 100644 index 632645a..0000000 --- a/script.py +++ /dev/null @@ -1,88 +0,0 @@ -import os -import sys -from subprocess import check_output - -from jinja2 import Environment, FileSystemLoader - -env = Environment(loader=FileSystemLoader('templates')) - - -class GitLog: - MARKER = '--PYCHOLOG MARKER--' - FORMAT = ( - '%H%n' # commit hash - '%an%n' # author name - '%ae%n' # author email - '%ad%n' # author date - '%cn%n' # committer name - '%ce%n' # committer email - '%cd%n' # committer date - '%D%n' # tag - '%s%n' # subject - '%b%n' + MARKER # body - ) - - COMMAND = ['git', 'log', '--tags', '--date=raw', '--format=' + FORMAT] - - def __init__(self, repository): - self.repository = repository - self.raw_log = self.get() - self.commits = self.parse_commits() - - def get(self): - # remove enclosing b-quotes (b'' or b"") - return str(check_output(self.COMMAND, cwd=self.repository))[2:-1].replace("\\'", "'") - - def parse_commits(self): - lines = self.raw_log.split('\\n') - size = len(lines) - 1 # don't count last blank line - commits = [] - pos = 0 - while pos < size: - commit = Commit( - commit_hash=lines[pos], - author_name=lines[pos+1], - author_email=lines[pos+2], - author_date=lines[pos+3], - committer_name=lines[pos+4], - committer_email=lines[pos+5], - committer_date=lines[pos+6], - tag=lines[pos+7], - subject=lines[pos+8], - body=[lines[pos+9]] - ) - commits.append(commit) - nbl_index = 10 - while lines[pos+nbl_index] != self.MARKER: - commit.body.append(lines[pos+nbl_index]) - nbl_index += 1 - pos += nbl_index + 1 - return commits - - -class Commit: - def __init__(self, commit_hash, - author_name=None, author_email=None, author_date=None, - committer_name=None, committer_email=None, committer_date=None, - tag=None, subject=None, body=None): - self.commit_hash = commit_hash - self.author_name = author_name - self.author_email = author_email - self.author_date = author_date - self.committer_name = committer_name - self.committer_email = committer_email - self.committer_date = committer_date - self.tag = tag - self.subject = subject - self.body = body or [] - - -if __name__ == '__main__': - commits = GitLog('example2').commits - template = env.get_template('changelog.md') - rendered = template.render(commits=commits) - with open('output.md', 'w') as stream: - stream.write(rendered) - for commit in commits: - print(commit.subject) - sys.exit(0) diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..2963499 --- /dev/null +++ b/setup.py @@ -0,0 +1,66 @@ +""" +Setup script. + +Uses setuptools. +Long description is a concatenation of README.md and CHANGELOG.md. +""" + +from __future__ import absolute_import, print_function + +import io +import os +from glob import glob + +from setuptools import find_packages, setup + + +def read(*names, **kwargs): + """Read a file in current directory.""" + return io.open( + os.path.join(os.path.dirname(__file__), *names), + encoding=kwargs.get('encoding', 'utf8') + ).read() + + +setup( + name='gitolog', + version='0.1.0', + license='ISC', + description='Automatic Changelog generator using Jinja2 templates.', + long_description='%s\n%s' % (read('README.md'), read('CHANGELOG.md')), + author='Timothée Mazzucotelli', + author_email='timothee.mazzucotelli@protonmail.com', + url='https://gitlab.com/pawamoy/gitolog', + packages=find_packages('src'), + package_dir={'': 'src'}, + py_modules=[os.path.splitext(os.path.basename(path))[0] for path in glob('src/*.py')], + include_package_data=True, + zip_safe=False, + classifiers=[ + # http://pypi.python.org/pypi?%3Aaction=list_classifiers + 'Development Status :: 3 - Alpha', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: ISC License (ISCL)', + 'Programming Language :: Python', + 'Programming Language :: Python :: 3', + 'Topic :: Software Development', + ], + keywords=[ + 'automatic', 'changelog', 'generator', 'automatic-changelog-generator', + 'keep-a-changelog', 'git', 'git-logs' + ], + install_requires=[ + 'jinja2' + ], + entry_points={ + 'console_scripts': [ + 'gitolog = gitolog.cli:main', + ], + # 'gitolog': [ + # 'style.atom = gitolog.styles:AtomStyle', + # 'style.angular = gitolog.styles:AngularStyle', + # 'template.angular = gitolog.templates:AngularTemplate', + # 'template.keepachangelog = gitolog.templates:KeepAChangelogTemplate', + # ] + }, +) diff --git a/src/gitolog/__init__.py b/src/gitolog/__init__.py new file mode 100644 index 0000000..172fe65 --- /dev/null +++ b/src/gitolog/__init__.py @@ -0,0 +1,6 @@ +""" +Gitolog package. +""" + +__version__ = '0.1.0' + diff --git a/src/gitolog/__main__.py b/src/gitolog/__main__.py new file mode 100644 index 0000000..7926919 --- /dev/null +++ b/src/gitolog/__main__.py @@ -0,0 +1,16 @@ +""" +Entrypoint module, in case you use `python -mgitolog`. + +Why does this file exist, and why __main__? For more info, read: + +- https://www.python.org/dev/peps/pep-0338/ +- https://docs.python.org/2/using/cmdline.html#cmdoption-m +- https://docs.python.org/3/using/cmdline.html#cmdoption-m +""" + +import sys + +from gitolog.cli import main + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/src/gitolog/build.py b/src/gitolog/build.py new file mode 100644 index 0000000..d638213 --- /dev/null +++ b/src/gitolog/build.py @@ -0,0 +1,299 @@ +import sys +from datetime import datetime +from subprocess import check_output + +from .style import CommitStyle, BasicStyle, AtomStyle, AngularStyle +from .providers import GitHub, GitLab + + +def bump(version, part='patch'): + major, minor, patch = version.split('.', 2) + patch = patch.split('-', 1) + pre = '' + if len(patch) > 1: + patch, pre = patch + else: + patch = patch[0] + if part == 'major': + major = str(int(major) + 1) + minor = patch = '0' + elif part == 'minor': + minor = str(int(minor) + 1) + patch = '0' + elif part == 'patch' and not pre: + patch = str(int(patch) + 1) + return '.'.join((major, minor, patch)) + + +class Commit: + def __init__( + self, hash, author_name='', author_email='', author_date='', + committer_name='', committer_email='', committer_date='', + refs='', subject='', body=None, url=''): + self.hash = hash + self.author_name = author_name + self.author_email = author_email + self.author_date = datetime.utcfromtimestamp(float(author_date)) + self.committer_name = committer_name + self.committer_email = committer_email + self.committer_date = datetime.utcfromtimestamp(float(committer_date)) + self.subject = subject + self.body = body or [] + self.url = url + + tag = '' + for ref in refs.split(','): + ref = ref.strip() + if ref.startswith('tag: '): + tag = ref.replace('tag: ', '') + break + self.tag = self.version = tag + + self.text_refs = {} + self.style = {} + + def update_with_style(self, style): + self.style.update(style.parse_commit(self)) + + def update_with_provider(self, provider): + # set the commit url based on provider + # FIXME: hardcoded 'commits' + if 'commits' in provider.REF: + self.url = provider.build_ref_url('commits', {'ref': self.hash}) + else: + # use default "commit" url (could be wrong) + self.url = '%s/%s/%s/commit/%s' % ( + provider.url, provider.namespace, + provider.project, self.hash) + + # build commit text references from its subject and body + for ref_type in provider.REF.keys(): + self.text_refs[ref_type] = provider.get_refs( + ref_type, '\n'.join([self.subject] + self.body)) + + if 'issues' in self.text_refs: + self.text_refs['issues_not_in_subject'] = [] + for issue in self.text_refs['issues']: + if issue.ref not in self.subject: + self.text_refs['issues_not_in_subject'].append(issue) + + +class Section: + def __init__(self, type='', commits=None): + self.type = type + self.commits = commits or [] + + +class Version: + def __init__(self, tag='', date='', sections=None, commits=None, url='', compare_url=''): + self.tag = tag + self.date = date + + self.sections_list = sections or [] + self.sections_dict = {s.type: s for s in self.sections_list} + self.commits = commits or [] + self.url = url + self.compare_url = compare_url + self.previous_version = None + self.next_version = None + + @property + def typed_sections(self): + return [s for s in self.sections_list if s.type] + + @property + def untyped_section(self): + return self.sections_dict.get('', None) + + @property + def is_major(self): + return self.tag.split('.', 1)[1].startswith('0.0') + + @property + def is_minor(self): + return self.tag.split('.', 2)[2] + + +class Gitolog: + MARKER = '--GITOLOG MARKER--' + FORMAT = ( + '%H%n' # commit hash + '%an%n' # author name + '%ae%n' # author email + '%ad%n' # author date + '%cn%n' # committer name + '%ce%n' # committer email + '%cd%n' # committer date + '%D%n' # tag + '%s%n' # subject + '%b%n' + MARKER # body + ) + STYLE = { + 'basic': BasicStyle, + 'angular': AngularStyle, + 'atom': AtomStyle + } + + def __init__(self, repository, provider=None, style=None): + self.repository = repository + + # set provider + if not provider: + remote_url = self.get_remote_url() + split = remote_url.split('/') + provider_url = '/'.join(split[:3]) + namespace, project = split[3], split[4] + if 'github' in provider_url: + provider = GitHub(namespace, project, url=provider_url) + elif 'gitlab' in provider_url: + provider = GitLab(namespace, project, url=provider_url) + self.remote_url = remote_url + self.provider = provider + + # set style + if isinstance(style, str): + try: + style = self.STYLE[style]() + except KeyError: + print('gitolog: no such style available: %s, ' + 'using default style' % style, file=sys.stderr) + style = BasicStyle() + elif style is None: + style = BasicStyle() + elif issubclass(style, CommitStyle): + style = style() + elif isinstance(style, CommitStyle): + pass + self.style = style + + # get git log and parse it into list of commits + self.raw_log = self.get_log() + self.commits = self.parse_commits() + + # apply dates to commits and group them by version + dates = self.apply_versions_to_commits() + versions = self.group_commits_by_version(dates) + self.versions_list = versions['as_list'] + self.versions_dict = versions['as_dict'] + + # guess the next version number based on last version and recent commits + if not self.versions_list[0].tag and len(self.versions_list) > 1: + last_tag = self.versions_list[1].tag + major = minor = False + for commit in self.versions_list[0].commits: + if commit.style['is_major']: + major = True + break + elif commit.style['is_minor']: + minor = True + if major: + planned_tag = bump(last_tag, 'major') + elif minor: + planned_tag = bump(last_tag, 'minor') + else: + planned_tag = bump(last_tag, 'patch') + self.versions_list[0].planned_tag = planned_tag + + def get_remote_url(self): + git_url = str(check_output( + ['git', 'config', '--get', 'remote.origin.url'], + cwd=self.repository))[2:-1].rstrip('\\n') + if git_url.startswith('git@'): + git_url = git_url.replace(':', '/', 1).replace('git@', 'https://', 1) + if git_url.endswith('.git'): + git_url = git_url[:-4] + return git_url + + def get_log(self): + # remove enclosing b-quotes (b'' or b"") + return str(check_output( + ['git', 'log', '--date=unix', '--format=' + self.FORMAT], + cwd=self.repository))[2:-1].replace("\\'", "'") + + def parse_commits(self): + lines = self.raw_log.split('\\n') + size = len(lines) - 1 # don't count last blank line + commits = [] + pos = 0 + while pos < size: + commit = Commit( + hash=lines[pos], + author_name=lines[pos+1], + author_email=lines[pos+2], + author_date=lines[pos+3], + committer_name=lines[pos+4], + committer_email=lines[pos+5], + committer_date=lines[pos+6], + refs=lines[pos+7], + subject=lines[pos+8], + body=[lines[pos+9]] + ) + + # append body lines + nbl_index = 10 + while lines[pos+nbl_index] != self.MARKER: + commit.body.append(lines[pos+nbl_index]) + nbl_index += 1 + pos += nbl_index + 1 + + # expand commit object with provider parsing + if self.provider: + commit.update_with_provider(self.provider) + + elif self.remote_url: + # set the commit url based on remote_url (could be wrong) + commit.url = self.remote_url + '/commit/' + commit.hash + + # expand commit object with style parsing + if self.style: + commit.update_with_style(self.style) + + commits.append(commit) + + return commits + + def apply_versions_to_commits(self): + versions_dates = {'': None} + version = None + for commit in self.commits: + if commit.version: + version = commit.version + versions_dates[version] = commit.committer_date.date() + elif version: + commit.version = version + return versions_dates + + def group_commits_by_version(self, dates): + versions_list = [] + versions_dict = {} + versions_types_dict = {} + next_version = None + for commit in self.commits: + if commit.version not in versions_dict: + version = versions_dict[commit.version] = Version( + tag=commit.version, date=dates[commit.version]) + version.url = self.provider.get_tag_url(tag=commit.version) + if next_version: + version.next_version = next_version + next_version.previous_version = version + next_version.compare_url = self.provider.build_ref_url( + # FIXME: hardcoded 'commits_ranges' + 'commits_ranges', {'ref': '%s...%s' % ( + version.tag, next_version.tag or 'HEAD')}) + next_version = version + versions_list.append(version) + versions_types_dict[commit.version] = {} + versions_dict[commit.version].commits.append(commit) + if 'type' in commit.style \ + and commit.style['type'] not in versions_types_dict[commit.version]: + section = versions_types_dict[commit.version][commit.style['type']] = Section( + type=commit.style['type']) + versions_dict[commit.version].sections_list.append(section) + versions_dict[commit.version].sections_dict = versions_types_dict[commit.version] + versions_types_dict[commit.version][commit.style['type']].commits.append(commit) + next_version.compare_url = self.provider.build_ref_url( + # FIXME: hardcoded 'commits_ranges' + 'commits_ranges', {'ref': '%s...%s' % ( + versions_list[-1].commits[-1].hash, next_version.tag or 'HEAD')}) + return {'as_list': versions_list, 'as_dict': versions_dict} + diff --git a/src/gitolog/cli.py b/src/gitolog/cli.py new file mode 100644 index 0000000..08e3b19 --- /dev/null +++ b/src/gitolog/cli.py @@ -0,0 +1,93 @@ +""" +Module that contains the command line application. + +Why does this file exist, and why not put this in __main__? + + You might be tempted to import things from __main__ later, + but that will cause problems: the code will get executed twice: + + - When you run `python -mgitolog` python will execute + ``__main__.py`` as a script. That means there won't be any + ``gitolog.__main__`` in ``sys.modules``. + - When you import __main__ it will get executed again (as a module) because + there's no ``gitolog.__main__`` in ``sys.modules``. + + Also see (1) from http://click.pocoo.org/5/setuptools/#setuptools-integration +""" + +import argparse +import sys + +from . import __version__, templates +from .build import Gitolog + + +STYLES = ('angular', 'atom', 'basic') + + +class Templates(tuple): + def __contains__(self, item): + return item.startswith('path:') or super(Templates, self).__contains__(item) + + +def get_parser(): + """Return a parser for the command-line arguments.""" + parser = argparse.ArgumentParser( + add_help=False, + description='Command line tool for gitolog Python package.') + + parser.add_argument( + 'repository', metavar='REPOSITORY', + help='The repository path, relative or absolute.') + + parser.add_argument( + '-h', '--help', action='help', default=argparse.SUPPRESS, + help='Show this help message and exit.') + parser.add_argument( + '-o', '--output', action='store', dest='output', default=sys.stdout, + help='Output to given file. Default: stdout.') + parser.add_argument( + '-s', '--style', choices=STYLES, default='basic', dest='style', + help='The commit style to match against.') + parser.add_argument( + '-t', '--template', choices=Templates(('angular', 'keepachangelog')), + default='keepachangelog', dest='template', + help='The Jinja2 template to use. Prefix with "path:" to specify the path ' + 'to a directory containing a file named "changelog.md".') + parser.add_argument( + '-v', '--version', action='version', + version='gitolog %s' % __version__, + help='Show the current version of the program and exit.') + return parser + + +def main(args=None): + parser = get_parser() + args = parser.parse_args(args=args) + + # get template + if args.template.startswith('path:'): + path = args.template.replace('path:', '', 1) + try: + template = templates.get_custom_template(path) + except FileNotFoundError: + print('gitolog: no such directory, ' + 'or missing changelog.md: %s' % path, file=sys.stderr) + return 1 + else: + template = templates.get_template(args.template) + + # build data + gitolog = Gitolog(args.repository, style=args.style) + + # get rendered contents + rendered = template.render(gitolog=gitolog) + + # write result in specified output + if args.output is sys.stdout: + sys.stdout.write(rendered) + else: + with open(args.output, 'w') as stream: + stream.write(rendered) + + return 0 diff --git a/src/gitolog/providers.py b/src/gitolog/providers.py new file mode 100644 index 0000000..fdd730d --- /dev/null +++ b/src/gitolog/providers.py @@ -0,0 +1,167 @@ +import re + + +class RefRe: + BB = r'(?:^|[\s,])' # blank before + BA = r'(?:[\s,]|$)' # blank after + NP = r'(?:(?P[-\w]+)/)?(?P[-\w]+)' # namespace and project + ID = r'{symbol}(?P[1-9]\d*)' + ONE_WORD = r'{symbol}(?P\w*[-a-z_ ][-\w]*)' + MULTI_WORD = r'{symbol}(?P"\w[- \w]*")' + COMMIT = r'(?P[0-9a-f]{{{min},{max}}})' + COMMIT_RANGE = r'(?P[0-9a-f]{{{min},{max}}}\.\.\.[0-9a-f]{{{min},{max}}})' + MENTION = r'@(?P\w[-\w]*)' + + +class Ref: + def __init__(self, ref, url): + self.ref = ref + self.url = url + + def __str__(self): + return self.ref + ': ' + self.url + + +class ProviderRefParser: + REF = {} + + def get_refs(self, ref_type, text): + return [ + Ref( + ref=match.group().strip(), + url=self.build_ref_url(ref_type, match.groupdict()) + ) for match in self.parse_refs(ref_type, text) + ] + + def parse_refs(self, ref_type, text): + if ref_type not in self.REF: + refs = [k for k in self.REF.keys() if k.startswith(ref_type)] + return [m for ref in refs for m in self.REF[ref].finditer(text)] + return [m for m in self.REF[ref_type]['regex'].finditer(text)] + + def build_ref_url(self, ref_type, match_dict): + return self.REF[ref_type]['url'].format(**match_dict) + + +class GitHub(ProviderRefParser): + url = 'https://github.com' + project_url = '{base_url}/{namespace}/{project}' + tag_url = '{base_url}/{namespace}/{project}/releases/tag/{ref}' + + REF = dict( + issues=dict( + regex=re.compile(RefRe.BB + RefRe.NP + '?' + RefRe.ID.format(symbol='#'), re.I), + url='{base_url}/{namespace}/{project}/issues/{ref}'), + commits=dict( + regex=re.compile(RefRe.BB + r'(?:{np}@)?{commit}{ba}'.format( + np=RefRe.NP, commit=RefRe.COMMIT.format(min=7, max=40), ba=RefRe.BA), re.I), + url='{base_url}/{namespace}/{project}/commit/{ref}'), + commits_ranges=dict( + regex=re.compile(RefRe.BB + r'(?:{np}@)?{commit_range}'.format( + np=RefRe.NP, commit_range=RefRe.COMMIT_RANGE.format(min=7, max=40)), re.I), + url='{base_url}/{namespace}/{project}/compare/{ref}'), + mentions=dict(regex=re.compile(RefRe.BB + RefRe.MENTION, re.I), url='{base_url}/{ref}') + ) + + def __init__(self, namespace, project, url=url): + self.namespace = namespace + self.project = project + self.url = url + + def build_ref_url(self, ref_type, match_dict): + match_dict['base_url'] = self.url + if not match_dict.get('namespace'): + match_dict['namespace'] = self.namespace + if not match_dict.get('project'): + match_dict['project'] = self.project + return super(GitHub, self).build_ref_url(ref_type, match_dict) + + def get_tag_url(self, tag=''): + return self.tag_url.format( + base_url=self.url, namespace=self.namespace, project=self.project, ref=tag) + + +class GitLab(ProviderRefParser): + url = 'https://gitlab.com' + project_url = '{base_url}/{namespace}/{project}' + tag_url = '{base_url}/{namespace}/{project}/tags/{ref}' + + REF = dict( + issues=dict( + regex=re.compile( + RefRe.BB + RefRe.NP + '?' + RefRe.ID.format(symbol='#'), re.I), + url='{base_url}/{namespace}/{project}/issues/{ref}' + ), + merge_requests=dict( + regex=re.compile( + RefRe.BB + RefRe.NP + '?' + RefRe.ID.format(symbol=r'!'), re.I), + url='{base_url}/{namespace}/{project}/merge_requests/{ref}' + ), + snippets=dict( + regex=re.compile( + RefRe.BB + RefRe.NP + '?' + RefRe.ID.format(symbol=r'\$'), re.I), + url='{base_url}/{namespace}/{project}/snippets/{ref}' + ), + labels_ids=dict( + regex=re.compile( + RefRe.BB + RefRe.NP + '?' + RefRe.ID.format(symbol=r'~'), re.I), + url='{base_url}/{namespace}/{project}/issues?label_name[]={ref}' # no label_id param? + ), + labels_one_word=dict( + regex=re.compile( # also matches label IDs + RefRe.BB + RefRe.NP + '?' + RefRe.ONE_WORD.format(symbol=r'~'), re.I), + url='{base_url}/{namespace}/{project}/issues?label_name[]={ref}' + ), + labels_multi_word=dict( + regex=re.compile( + RefRe.BB + RefRe.NP + '?' + RefRe.MULTI_WORD.format(symbol=r'~'), re.I), + url='{base_url}/{namespace}/{project}/issues?label_name[]={ref}' + ), + milestones_ids=dict( + regex=re.compile( # also matches milestones IDs + RefRe.BB + RefRe.NP + '?' + RefRe.ID.format(symbol=r'%'), re.I), + url='{base_url}/{namespace}/{project}/milestones/{ref}' + ), + milestones_one_word=dict( + regex=re.compile( + RefRe.BB + RefRe.NP + '?' + RefRe.ONE_WORD.format(symbol=r'%'), re.I), + url='{base_url}/{namespace}/{project}/milestones' # cannot guess ID + ), + milestones_multi_word=dict( + regex=re.compile( + RefRe.BB + RefRe.NP + '?' + RefRe.MULTI_WORD.format(symbol=r'%'), re.I), + url='{base_url}/{namespace}/{project}/milestones' # cannot guess ID + ), + commits=dict( + regex=re.compile( + RefRe.BB + r'(?:{np}@)?{commit}{ba}'.format( + np=RefRe.NP, commit=RefRe.COMMIT.format(min=8, max=40), ba=RefRe.BA), re.I), + url='{base_url}/{namespace}/{project}/commit/{ref}' + ), + commits_ranges=dict( + regex=re.compile( + RefRe.BB + r'(?:{np}@)?{commit_range}'.format( + np=RefRe.NP, commit_range=RefRe.COMMIT_RANGE.format(min=8, max=40)), re.I), + url='{base_url}/{namespace}/{project}/compare/{ref}' + ), + mentions=dict(regex=re.compile(RefRe.BB + RefRe.MENTION, re.I), url='{base_url}/{ref}') + ) + + def __init__(self, namespace, project, url=url): + self.namespace = namespace + self.project = project + self.url = url + + def build_ref_url(self, ref_type, match_dict): + match_dict['base_url'] = self.url + if not match_dict.get('namespace'): + match_dict['namespace'] = self.namespace + if not match_dict.get('project'): + match_dict['project'] = self.project + if ref_type.startswith('label'): + match_dict['ref'] = match_dict['ref'].replace('"', '').replace(' ', '+') + return super(GitLab, self).build_ref_url(ref_type, match_dict) + + def get_tag_url(self, tag=''): + return self.tag_url.format( + base_url=self.url, namespace=self.namespace, project=self.project, ref=tag) diff --git a/src/gitolog/style.py b/src/gitolog/style.py new file mode 100644 index 0000000..ed8e874 --- /dev/null +++ b/src/gitolog/style.py @@ -0,0 +1,117 @@ +import re + + +class CommitStyle: + def parse_commit(self, commit): + raise NotImplementedError + + +class BasicStyle(CommitStyle): + TYPES = { + 'add': 'Added', + 'fix': 'Fixed', + 'change': 'Changed', + 'remove': 'Removed', + 'merge': 'Merged', + 'doc': 'Documented' + } + + TYPE_REGEX = re.compile(r'^(?P(%s))' % '|'.join(TYPES.keys()), re.I) + BREAK_REGEX = re.compile(r'^break(s|ing changes)?[ :].+$', re.I) + + def parse_commit(self, commit): + commit_type = self.parse_type(commit.subject) + message = '\n'.join([commit.subject] + commit.body) + is_major = self.is_major(message) + is_minor = not is_major and self.is_minor(commit_type) + is_patch = not any((is_major, is_minor)) + + return dict( + type=commit_type, + is_major=is_major, + is_minor=is_minor, + is_patch=is_patch + ) + + def parse_type(self, commit_subject): + type_match = self.TYPE_REGEX.match(commit_subject) + if type_match: + return self.TYPES.get(type_match.groupdict().get('type').lower()) + return '' + + def is_minor(self, commit_type): + return commit_type == self.TYPES['add'] + + def is_major(self, commit_message): + return bool(self.BREAK_REGEX.match(commit_message)) + + +class AngularStyle(CommitStyle): + TYPES = { + # 'build': 'Build', + # 'ci': 'CI', + 'perf': 'Performance Improvements', + 'feat': 'Features', + 'fix': 'Bug Fixes', + 'revert': 'Reverts', + # 'docs': 'Docs', + # 'style': '', + 'refactor': 'Code Refactoring', + # 'test': '', + # 'chore': '', + } + SUBJECT_REGEX = re.compile( + r'^(?P(%s))(?:\((?P.+)\))?: (?P.+)$' % ('|'.join(TYPES.keys()))) + BREAK_REGEX = re.compile(r'^break(s|ing changes)?[ :].+$', re.I) + + def parse_commit(self, commit): + subject = self.parse_subject(commit.subject) + message = '\n'.join([commit.subject] + commit.body) + is_major = self.is_major(message) + is_minor = not is_major and self.is_minor(subject['type']) + is_patch = not any((is_major, is_minor)) + + return dict( + type=subject['type'], + scope=subject['scope'], + subject=subject['subject'], + is_major=is_major, + is_minor=is_minor, + is_patch=is_patch + ) + + def parse_subject(self, commit_subject): + subject_match = self.SUBJECT_REGEX.match(commit_subject) + if subject_match: + dct = subject_match.groupdict() + dct['type'] = self.TYPES[dct['type']] + return dct + return {'type': '', 'scope': '', 'subject': commit_subject} + + @staticmethod + def is_minor(commit_type): + return commit_type == 'feat' + + def is_major(self, commit_message): + return bool(self.BREAK_REGEX.match(commit_message)) + + +class AtomStyle(CommitStyle): + TYPES = { + ':art:': '', # when improving the format/structure of the code + ':racehorse:': '', # when improving performance + ':non-potable_water:': '', # when plugging memory leaks + ':memo:': '', # when writing docs + ':penguin:': '', # when fixing something on Linux + ':apple:': '', # when fixing something on Mac OS + ':checkered_flag:': '', # when fixing something on Windows + ':bug:': '', # when fixing a bug + ':fire:': '', # when removing code or files + ':green_heart:': '', # when fixing the CI build + ':white_check_mark:': '', # when adding tests + ':lock:': '', # when dealing with security + ':arrow_up:': '', # when upgrading dependencies + ':arrow_down:': '', # when downgrading dependencies + ':shirt:': '', # when removing linter warnings + } + diff --git a/src/gitolog/templates/__init__.py b/src/gitolog/templates/__init__.py new file mode 100644 index 0000000..81bf81f --- /dev/null +++ b/src/gitolog/templates/__init__.py @@ -0,0 +1,24 @@ +import os + +from jinja2 import Environment, FileSystemLoader +from jinja2.exceptions import TemplateNotFound + + +def get_path(): + return os.path.dirname(os.path.abspath(__file__)) + + +def get_env(path): + return Environment(loader=FileSystemLoader(path)) + + +def get_custom_template(path): + try: + return get_env(os.path.abspath(path)).get_template('changelog.md') + except TemplateNotFound: + raise FileNotFoundError + + +def get_template(name): + return get_env(os.path.join(get_path(), name)).get_template('changelog.md') + diff --git a/src/gitolog/templates/angular/changelog.md b/src/gitolog/templates/angular/changelog.md new file mode 100644 index 0000000..55b484b --- /dev/null +++ b/src/gitolog/templates/angular/changelog.md @@ -0,0 +1,9 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) +and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). + +{% for version in gitolog.versions_list -%} +{% include 'version.md' with context %} +{% endfor -%} diff --git a/src/gitolog/templates/angular/commit.md b/src/gitolog/templates/angular/commit.md new file mode 100644 index 0000000..242c9e4 --- /dev/null +++ b/src/gitolog/templates/angular/commit.md @@ -0,0 +1,4 @@ +- {% if commit.style.scope %}**{{ commit.style.scope }}:** {% endif %}{{ commit.style.subject }} ([{{ commit.hash|truncate(7, True, '') }}]({{ commit.url }})) +{%- if commit.text_refs.issues_not_in_subject %}, related to {% for issue in commit.text_refs.issues_not_in_subject -%} +[{{ issue.ref }}]({{ issue.url }}){% if not loop.last %}, {% endif -%} +{%- endfor -%}{%- endif -%} \ No newline at end of file diff --git a/src/gitolog/templates/angular/section.md b/src/gitolog/templates/angular/section.md new file mode 100644 index 0000000..e124048 --- /dev/null +++ b/src/gitolog/templates/angular/section.md @@ -0,0 +1,4 @@ +### {{ section.type or "Misc" }} +{% for commit in section.commits|sort(attribute='subject') -%} +{% include 'commit.md' with context %} +{% endfor %} diff --git a/src/gitolog/templates/angular/version.md b/src/gitolog/templates/angular/version.md new file mode 100644 index 0000000..992f163 --- /dev/null +++ b/src/gitolog/templates/angular/version.md @@ -0,0 +1,12 @@ +{%- if version.tag -%} + +## [{{ version.tag }}]({{ version.compare_url }}) +{%- else -%} + +## {{ version.planned_tag or "Unrealeased" }} ([compare]({{ version.compare_url }})){%- endif -%}{% if version.date %} ({{ version.date }}){% endif %} + +{% for type, section in version.sections_dict|dictsort -%} +{%- if type and type != 'Merged' -%} +{% include 'section.md' with context %} +{% endif -%} +{%- endfor -%} diff --git a/src/gitolog/templates/keepachangelog/changelog.md b/src/gitolog/templates/keepachangelog/changelog.md new file mode 100644 index 0000000..55b484b --- /dev/null +++ b/src/gitolog/templates/keepachangelog/changelog.md @@ -0,0 +1,9 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) +and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). + +{% for version in gitolog.versions_list -%} +{% include 'version.md' with context %} +{% endfor -%} diff --git a/src/gitolog/templates/keepachangelog/commit.md b/src/gitolog/templates/keepachangelog/commit.md new file mode 100644 index 0000000..c9a32f1 --- /dev/null +++ b/src/gitolog/templates/keepachangelog/commit.md @@ -0,0 +1,4 @@ +- {{ commit.subject }} ([{{ commit.hash|truncate(7, True, '') }}]({{ commit.url }})). +{%- if commit.text_refs.issues_not_in_subject %} Related issues/PRs: {% for issue in commit.text_refs.issues_not_in_subject -%} +{{ issue.ref }}{% if not loop.last %}, {% endif -%} +{%- endfor -%}{%- endif -%} \ No newline at end of file diff --git a/src/gitolog/templates/keepachangelog/section.md b/src/gitolog/templates/keepachangelog/section.md new file mode 100644 index 0000000..e124048 --- /dev/null +++ b/src/gitolog/templates/keepachangelog/section.md @@ -0,0 +1,4 @@ +### {{ section.type or "Misc" }} +{% for commit in section.commits|sort(attribute='subject') -%} +{% include 'commit.md' with context %} +{% endfor %} diff --git a/src/gitolog/templates/keepachangelog/version.md b/src/gitolog/templates/keepachangelog/version.md new file mode 100644 index 0000000..9d9fcdb --- /dev/null +++ b/src/gitolog/templates/keepachangelog/version.md @@ -0,0 +1,15 @@ +{%- if version.tag -%} +## [{{ version.tag }}]({{ version.url }}) ([compare]({{ version.compare_url }})) +{%- else -%} +## {{ version.planned_tag or "Unrealeased" }} ([compare]({{ version.compare_url }})){%- endif -%}{% if version.date %} - {{ version.date }}{% endif %} + +{% for type, section in version.sections_dict|dictsort -%} +{%- if type and type != 'Merged' -%} +{% include 'section.md' with context %} +{% endif -%} +{%- endfor -%} +{%- if version.untyped_section -%} +{%- with section = version.untyped_section -%} +{% include 'section.md' with context %} +{% endwith -%} +{%- endif -%} diff --git a/templates/changelog.md b/templates/changelog.md deleted file mode 100644 index d487e5c..0000000 --- a/templates/changelog.md +++ /dev/null @@ -1,5 +0,0 @@ -This is my changelog - -{% for commit in commits -%} - {% include 'commit.md' with context %} -{% endfor %} diff --git a/templates/commit.md b/templates/commit.md deleted file mode 100644 index 1eddb92..0000000 --- a/templates/commit.md +++ /dev/null @@ -1,12 +0,0 @@ -commit {{ commit.commit_hash }} -Author name: {{ commit.author_name }} -Author email: {{ commit.author_email }} -Author date: {{ commit.author_date }} -Committer name: {{ commit.committer_name }} -Committer email: {{ commit.committer_email }} -Committer date: {{ commit.committer_date }} -{% if commit.tag %}Tag: {{ commit.tag }}{% endif %} -Subject: {{ commit.subject }} -Body: {% for line in commit.body %} -{% if line %}{{ line }}{% endif %} -{% endfor %} \ No newline at end of file diff --git a/tests.py b/tests.py new file mode 100644 index 0000000..2a79204 --- /dev/null +++ b/tests.py @@ -0,0 +1,45 @@ +import gitolog + +text = """ +This is the subject #1 + +This is the body. Related: #2. Also mentions #3 and #4. +Closes #5. closed #6, #7. FIX: #89 and #10. +Resolve #1111. +Also support other projects references like shellm-org/shellm-data#19!! +Or fix pawamoy/gitolog#1. +Don't match this one: #01. + +Now some other references: + +A merge request: !153! +A mention: @hello +A commit gitolog@06abf793 +A longer commit 3879fabda896da89954adec8 +A commit range: 00000000...11111111 +A snippet: $45 +Some labels: ~18, ~bug, ~"multi word label" +Some milestones: %2, %version1, %"awesome version" +""" + + +def test_github_issue_parsing(): + github = gitolog.GitHub('pawamoy', 'gitolog') + for ref in github.REF.keys(): + refs = github.get_refs(ref, text) + print('\n'.join(map(str, refs))) + + +def test_gitlab_issue_parsing(): + gitlab = gitolog.GitLab('pawamoy', 'gitolog') + for ref in gitlab.REF.keys(): + refs = gitlab.get_refs(ref, text) + print('\n'.join(map(str, refs))) + + +if __name__ == '__main__': + print('Searching references for GitHub') + test_github_issue_parsing() + print('-------------------------------') + print('Searching references for GitLab') + test_gitlab_issue_parsing()