Repository: relekang/python-semantic-release (python)

Issue:
Changelog generation
Changelog generation requires more of the commit messages than just bumping version number.

It would be great to have support for custom commit message evaluation:

By defining import path of the evaluation function in setup.cfg:
```
[semantic_release]
commit_evaluator = semantic_release.history.commit_evaluator
```

The evaluators should take one argument, a string of a commit message, and return a tuple of `(bumb_level_string, changelog_section, changelog_string)`.

The changelog should have 5 sections(please comment if something important is left out):
* Breaking changes
* Feature changes
* Patch changes
* Documentation changes
* Other changes

The headers of these sections can be `changelog_section.capitilize()` (from the evaluator tuple) and thus be customizable.

Pull request:
[WIP] Add changelog generation
Fixes #17

- [x] Parse commit log
- [x] Generate categorised lists of changes
- [x] Add cli command to print changelog
- [x] Generate markdown changelog
- [ ] Generate rst changelog
- [ ] Append to CHANGES/CHANGELOG file and commit on `version`
- [ ] Put changelog to Github Releases page on `publish`

Patch:
diff --git a/semantic_release/__init__.py b/semantic_release/__init__.py
index 016d0b4..de050d9 100644
--- a/semantic_release/__init__.py
+++ b/semantic_release/__init__.py
@@ -7,6 +7,6 @@ def setup_hook(argv):
 
     :param argv: sys.argv
     """
-    if len(argv) > 1 and argv[1] in ['version', 'publish']:
+    if len(argv) > 1 and argv[1] in ['version', 'publish', 'changelog']:
         from semantic_release.cli import main
         main()
diff --git a/semantic_release/cli.py b/semantic_release/cli.py
index 2fb3030..ab0be40 100644
--- a/semantic_release/cli.py
+++ b/semantic_release/cli.py
@@ -1,7 +1,8 @@
 import click
 
 from .history import evaluate_version_bump, get_current_version, get_new_version, set_new_version
-from .hvcs import check_build_status
+from .history.logs import CHANGELOG_SECTIONS, generate_changelog, markdown_changelog
+from .hvcs import check_build_status, check_token, post_changelog
 from .pypi import upload_to_pypi
 from .settings import config
 from .vcs_helpers import (commit_new_version, get_current_head_hash, get_repository_owner_and_name,
@@ -63,16 +64,49 @@ def version(**kwargs):
     return True
 
 
+def changelog(**kwargs):
+    """
+    Generates the changelog since the last release.
+    """
+    current_version = get_current_version()
+    log = generate_changelog(current_version)
+    for section in CHANGELOG_SECTIONS:
+        if len(log[section]) == 0:
+            continue
+
+        click.echo(section.capitalize())
+        click.echo(''.join(['-' for i in range(len(section))]))
+        for item in log[section]:
+            click.echo(' - {}'.format(item))
+        click.echo('\n')
+
+
 def publish(**kwargs):
     """
     Runs the version task before pushing to git and uploading to pypi.
     """
+    current_version = get_current_version()
+    click.echo('Current version: {0}'.format(current_version))
+    level_bump = evaluate_version_bump(current_version, kwargs['force_level'])
+    new_version = get_new_version(current_version, level_bump)
     if version(**kwargs):
         push_new_version()
         upload_to_pypi()
+        owner, name = get_repository_owner_and_name()
+        if check_token():
+            click.echo('Updating changelog')
+            post_changelog(
+                owner,
+                name,
+                new_version,
+                markdown_changelog(new_version, generate_changelog(new_version), header=False)
+            )
+        else:
+            click.echo(click.style('Missing token: cannot post changelog', 'red'), err=True)
+
         click.echo(click.style('New release published', 'green'))
     else:
-        click.echo('Version failed, no release will be published.')
+        click.echo('Version failed, no release will be published.', err=True)
 
 
 if __name__ == '__main__':
diff --git a/semantic_release/defaults.cfg b/semantic_release/defaults.cfg
index 27df35a..8548935 100644
--- a/semantic_release/defaults.cfg
+++ b/semantic_release/defaults.cfg
@@ -5,3 +5,4 @@ patch_tag=:bug:
 patch_without_tag=false
 check_build_status=false
 hvcs=github
+commit_parser=semantic_release.history.angular_parser
diff --git a/semantic_release/errors.py b/semantic_release/errors.py
index 89d6357..920e1de 100644
--- a/semantic_release/errors.py
+++ b/semantic_release/errors.py
@@ -7,3 +7,7 @@ class SemanticReleaseBaseError(Exception):
 
 class ImproperConfigurationError(SemanticReleaseBaseError):
     pass
+
+
+class UnknownCommitMessageStyle(SemanticReleaseBaseError):
+    pass
diff --git a/semantic_release/history.py b/semantic_release/history/__init__.py
similarity index 53%
rename from semantic_release/history.py
rename to semantic_release/history/__init__.py
index 6337bea..a4d3e13 100644
--- a/semantic_release/history.py
+++ b/semantic_release/history/__init__.py
@@ -3,49 +3,10 @@ import re
 import semver
 from invoke import run
 
-from .settings import config
-from .vcs_helpers import get_commit_log
+from ..settings import config
+from .logs import evaluate_version_bump  # noqa
 
-LEVELS = {
-    1: 'patch',
-    2: 'minor',
-    3: 'major',
-}
-
-
-def evaluate_version_bump(current_version, force=None):
-    """
-    Reads git log since last release to find out if should be a major, minor or patch release.
-
-    :param current_version: A string with the current version number.
-    :param force: A string with the bump level that should be forced.
-    :return: A string with either major, minor or patch if there should be a release. If no release
-             is necessary None will be returned.
-    """
-    if force:
-        return force
-
-    bump = None
-
-    changes = []
-    commit_count = 0
-
-    for commit_message in get_commit_log():
-        if current_version in commit_message:
-            break
-        if config.get('semantic_release', 'major_tag') in commit_message:
-            changes.append(3)
-        elif config.get('semantic_release', 'minor_tag') in commit_message:
-            changes.append(2)
-        elif config.get('semantic_release', 'patch_tag') in commit_message:
-            changes.append(1)
-        commit_count += 1
-
-    if len(changes):
-        bump = LEVELS[max(changes)]
-    if config.getboolean('semantic_release', 'patch_without_tag') and commit_count:
-        bump = 'patch'
-    return bump
+from .parser_angular import parse_commit_message as angular_parser  # noqa isort:skip
 
 
 def get_current_version():
diff --git a/semantic_release/history/logs.py b/semantic_release/history/logs.py
new file mode 100644
index 0000000..82e5566
--- /dev/null
+++ b/semantic_release/history/logs.py
@@ -0,0 +1,111 @@
+import re
+
+from ..errors import UnknownCommitMessageStyle
+from ..settings import config, current_commit_parser
+from ..vcs_helpers import get_commit_log
+
+LEVELS = {
+    1: 'patch',
+    2: 'minor',
+    3: 'major',
+}
+
+CHANGELOG_SECTIONS = ['feature', 'fix', 'breaking', 'documentation']
+
+re_breaking = re.compile('BREAKING CHANGE: (.*)')
+
+
+def evaluate_version_bump(current_version, force=None):
+    """
+    Reads git log since last release to find out if should be a major, minor or patch release.
+
+    :param current_version: A string with the current version number.
+    :param force: A string with the bump level that should be forced.
+    :return: A string with either major, minor or patch if there should be a release. If no release
+             is necessary None will be returned.
+    """
+    if force:
+        return force
+
+    bump = None
+
+    changes = []
+    commit_count = 0
+
+    for commit_message in get_commit_log():
+        if current_version in commit_message:
+            break
+
+        try:
+            message = current_commit_parser()(commit_message)
+            changes.append(message[0])
+        except UnknownCommitMessageStyle:
+            pass
+
+        commit_count += 1
+
+    if len(changes):
+        level = max(changes)
+        if level in LEVELS:
+            bump = LEVELS[level]
+    if config.getboolean('semantic_release', 'patch_without_tag') and commit_count:
+        bump = 'patch'
+    return bump
+
+
+def generate_changelog(version):
+    """
+    Generates a changelog for the given version.
+
+    :param version: a version string
+    :return: a dict with different changelog sections
+    """
+
+    changes = {'feature': [], 'fix': [], 'documentation': [], 'refactor': [], 'breaking': []}
+
+    for commit_message in get_commit_log():
+        if version in commit_message:
+            break
+
+        try:
+            message = current_commit_parser()(commit_message)
+            if message[1] not in changes:
+                continue
+
+            changes[message[1]].append(message[3][0])
+
+            if message[3][1] and 'BREAKING CHANGE' in message[3][1]:
+                changes['breaking'].append(re_breaking.match(message[3][1]).group(1))
+
+            if message[3][2] and 'BREAKING CHANGE' in message[3][2]:
+                changes['breaking'].append(re_breaking.match(message[3][2]).group(1))
+
+        except UnknownCommitMessageStyle:
+            pass
+
+    return changes
+
+
+def markdown_changelog(version, changelog, header=False):
+    """
+    Generates a markdown version of the changelog. Takes a parsed changelog dict from
+    generate_changelog.
+
+    :param version: A string with the version number.
+    :param changelog: A dict from generate_changelog.
+    :param header: A boolean that decides whether a header should be included or not.
+    :return: The markdown formatted changelog.
+    """
+    output = ''
+    if header:
+        output += '## v{0}\n'.format(version)
+
+    for section in CHANGELOG_SECTIONS:
+        if len(changelog[section]) == 0:
+            continue
+
+        output += '\n### {0}\n'.format(section.capitalize())
+        for item in changelog[section]:
+            output += '* {0}\n'.format(item)
+
+    return output
diff --git a/semantic_release/history/parser_angular.py b/semantic_release/history/parser_angular.py
new file mode 100644
index 0000000..20a2976
--- /dev/null
+++ b/semantic_release/history/parser_angular.py
@@ -0,0 +1,61 @@
+import re
+
+from semantic_release.errors import UnknownCommitMessageStyle
+
+re_parser = re.compile(
+    r'(?P<type>feat|fix|docs|style|refactor|test|chore)'
+    r'\((?P<scope>[\w _\-]+)\): '
+    r'(?P<subject>[^\n]+)'
+    r'(:?\n\n(?P<text>.+))?',
+    re.DOTALL
+)
+
+TYPES = {
+    'feat': 'feature',
+    'fix': 'fix',
+    'test': 'test',
+    'docs': 'documentation',
+    'style': 'style',
+    'refactor': 'refactor',
+    'chore': 'chore',
+}
+
+
+def parse_commit_message(message):
+    """
+    Parses a commit message according to the angular commit guidelines specification.
+
+    :param message: A string of a commit message.
+    :return: A tuple of (level to bump, type of change, scope of change, a tuple with descriptions)
+    """
+
+    if not re_parser.match(message):
+        raise UnknownCommitMessageStyle(
+            'Unable to parse the given commit message: {}'.format(message)
+        )
+
+    parsed = re_parser.match(message)
+    level_bump = 0
+    if parsed.group('text') and 'BREAKING CHANGE' in parsed.group('text'):
+        level_bump = 3
+
+    if parsed.group('type') == 'feat':
+        level_bump = max([level_bump, 2])
+
+    if parsed.group('type') == 'fix':
+        level_bump = max([level_bump, 1])
+
+    text = parsed.group('text')
+    body = ''
+    footer = ''
+    if text:
+        body = text.split('\n\n')[0]
+        if len(text.split('\n\n')) == 2:
+            footer = text.split('\n\n')[1]
+
+    return (
+        level_bump,
+        TYPES[parsed.group('type')],
+        parsed.group('scope'),
+        (parsed.group('subject'), body.replace('\n', ' '), footer.replace('\n', ' '))
+    )
diff --git a/semantic_release/hvcs.py b/semantic_release/hvcs.py
index d02b053..51fb3f9 100644
--- a/semantic_release/hvcs.py
+++ b/semantic_release/hvcs.py
@@ -1,3 +1,5 @@
+import os
+
 import requests
 
 from .errors import ImproperConfigurationError
@@ -6,12 +8,26 @@ from .settings import config
 
 class Github(object):
 
+    @staticmethod
+    def token():
+        return os.environ.get('GH_TOKEN')
+
     @staticmethod
     def check_build_status(owner, repository, ref):
         url = 'https://api.github.com/repos/{owner}/{repo}/commits/{ref}/status'
         response = requests.get(url.format(owner=owner, repo=repository, ref=ref))
         return response.json()['state'] == 'success'
 
+    @classmethod
+    def post_release_changelog(cls, owner, repository, version, changelog):
+        url = 'https://api.github.com/repos/{owner}/{repo}/releases?access_token={token}'
+        tag = 'v{0}'.format(version)
+        response = requests.post(
+            url.format(owner=owner, repo=repository, token=Github.token()),
+            json={'tag_name': tag, 'body': changelog, 'draft': False, 'prerelease': False}
+        )
+        return response.status_code == 201, response.json()
+
 
 def get_hvcs():
     hvcs = config.get('semantic_release', 'hvcs')
@@ -31,3 +47,20 @@ def check_build_status(owner, repository, ref):
     :return: A boolean with the build status
     """
     return get_hvcs().check_build_status(owner, repository, ref)
+
+
+def post_changelog(owner, repository, version, changelog):
+    """
+    Posts the changelog to the current hcvs release API
+
+    :param owner: The owner of the repository
+    :param repository: The repository name
+    :param version: A string with the new version
+    :param changelog: A string with the changelog in correct format
+    :return: a tuple with successtatus and payload from hvcs
+    """
+    return get_hvcs().post_release_changelog(owner, repository, version, changelog)
+
+
+def check_token():
+    return get_hvcs().token() is not None
diff --git a/semantic_release/settings.py b/semantic_release/settings.py
index 40e7e03..3fe9dde 100644
--- a/semantic_release/settings.py
+++ b/semantic_release/settings.py
@@ -1,6 +1,9 @@
+import importlib
 import os
 from os import getcwd
 
+from .errors import ImproperConfigurationError
+
 try:
     import configparser
 except ImportError:
@@ -16,3 +19,12 @@ def _config():
     return parser
 
 config = _config()
+
+
+def current_commit_parser():
+    try:
+        parts = config.get('semantic_release', 'commit_parser').split('.')
+        module = '.'.join(parts[:len(parts) - 1])
+        return getattr(importlib.import_module(module), parts[-1])
+    except (ImportError, AttributeError) as error:
+        raise ImproperConfigurationError('Unable to import parser "{}"'.format(error))


Test patch:
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 82b82e5..98b9dbf 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -14,8 +14,8 @@ class CLITests(TestCase):
     @mock.patch('semantic_release.cli.version')
     def test_main_should_call_correct_function(self, mock_version):
         result = self.runner.invoke(main, ['version'])
-        self.assertEqual(result.exit_code, 0)
         mock_version.assert_called_once_with(noop=False, force_level=None)
+        self.assertEqual(result.exit_code, 0)
 
     @mock.patch('semantic_release.cli.config.getboolean', lambda *x: False)
     @mock.patch('semantic_release.cli.tag_new_version')
@@ -28,34 +28,34 @@ class CLITests(TestCase):
                                                    mock_new_version, mock_set_new_version,
                                                    mock_commit_new_version, mock_tag_new_version):
         result = self.runner.invoke(main, ['version'])
-        self.assertEqual(result.exit_code, 0)
         mock_current_version.assert_called_once_with()
         mock_evaluate_bump.assert_called_once_with('1.2.3', None)
         mock_new_version.assert_called_once_with('1.2.3', 'major')
         mock_set_new_version.assert_called_once_with('2.0.0')
         mock_commit_new_version.assert_called_once_with('2.0.0')
         mock_tag_new_version.assert_called_once_with('2.0.0')
+        self.assertEqual(result.exit_code, 0)
 
     @mock.patch('semantic_release.cli.version')
     def test_force_major(self, mock_version):
         result = self.runner.invoke(main, ['version', '--major'])
-        self.assertEqual(result.exit_code, 0)
         mock_version.assert_called_once_with(noop=False, force_level='major')
         self.assertEqual(mock_version.call_args_list[0][1]['force_level'], 'major')
+        self.assertEqual(result.exit_code, 0)
 
     @mock.patch('semantic_release.cli.version')
     def test_force_minor(self, mock_version):
         result = self.runner.invoke(main, ['version', '--minor'])
-        self.assertEqual(result.exit_code, 0)
         mock_version.assert_called_once_with(noop=False, force_level='minor')
         self.assertEqual(mock_version.call_args_list[0][1]['force_level'], 'minor')
+        self.assertEqual(result.exit_code, 0)
 
     @mock.patch('semantic_release.cli.version')
     def test_force_patch(self, mock_version):
         result = self.runner.invoke(main, ['version', '--patch'])
-        self.assertEqual(result.exit_code, 0)
         mock_version.assert_called_once_with(noop=False, force_level='patch')
         self.assertEqual(mock_version.call_args_list[0][1]['force_level'], 'patch')
+        self.assertEqual(result.exit_code, 0)
 
     @mock.patch('semantic_release.cli.tag_new_version')
     @mock.patch('semantic_release.cli.evaluate_version_bump', lambda *x: 'major')
@@ -63,10 +63,10 @@ class CLITests(TestCase):
     @mock.patch('semantic_release.cli.set_new_version')
     def test_noop_mode(self, mock_set_new, mock_commit_new, mock_tag_new_version):
         result = self.runner.invoke(main, ['version', '--noop'])
-        self.assertEqual(result.exit_code, 0)
         self.assertFalse(mock_set_new.called)
         self.assertFalse(mock_commit_new.called)
         self.assertFalse(mock_tag_new_version.called)
+        self.assertEqual(result.exit_code, 0)
 
     @mock.patch('semantic_release.cli.tag_new_version')
     @mock.patch('semantic_release.cli.commit_new_version')
@@ -78,13 +78,13 @@ class CLITests(TestCase):
                                mock_new_version, mock_set_new_version,
                                mock_commit_new_version, mock_tag_new_version):
         result = self.runner.invoke(main, ['version'])
-        self.assertEqual(result.exit_code, 0)
         mock_current_version.assert_called_once_with()
         mock_evaluate_bump.assert_called_once_with('1.2.3', None)
         mock_new_version.assert_called_once_with('1.2.3', None)
         self.assertFalse(mock_set_new_version.called)
         self.assertFalse(mock_commit_new_version.called)
         self.assertFalse(mock_tag_new_version.called)
+        self.assertEqual(result.exit_code, 0)
 
     @mock.patch('semantic_release.cli.config.getboolean', lambda *x: True)
     @mock.patch('semantic_release.cli.check_build_status', return_value=False)
@@ -95,11 +95,11 @@ class CLITests(TestCase):
     def test_version_check_build_status_fails(self, mock_set_new, mock_commit_new,
                                               mock_tag_new_version, mock_check_build_status):
         result = self.runner.invoke(main, ['version'])
-        self.assertEqual(result.exit_code, 0)
         self.assertTrue(mock_check_build_status.called)
         self.assertFalse(mock_set_new.called)
         self.assertFalse(mock_commit_new.called)
         self.assertFalse(mock_tag_new_version.called)
+        self.assertEqual(result.exit_code, 0)
 
     @mock.patch('semantic_release.cli.config.getboolean', lambda *x: True)
     @mock.patch('semantic_release.cli.check_build_status', return_value=True)
@@ -110,11 +110,11 @@ class CLITests(TestCase):
     def test_version_check_build_status_succeeds(self, mock_set_new, mock_commit_new,
                                                  mock_tag_new_version, mock_check_build_status):
         result = self.runner.invoke(main, ['version'])
-        self.assertEqual(result.exit_code, 0)
         self.assertTrue(mock_check_build_status.called)
         self.assertTrue(mock_set_new.called)
         self.assertTrue(mock_commit_new.called)
         self.assertTrue(mock_tag_new_version.called)
+        self.assertEqual(result.exit_code, 0)
 
     @mock.patch('semantic_release.cli.config.getboolean', lambda *x: False)
     @mock.patch('semantic_release.cli.check_build_status')
@@ -126,22 +126,35 @@ class CLITests(TestCase):
         self.runner.invoke(main, ['version'])
         self.assertFalse(mock_check_build_status.called)
 
+    @mock.patch('semantic_release.cli.post_changelog')
     @mock.patch('semantic_release.cli.upload_to_pypi')
     @mock.patch('semantic_release.cli.push_new_version')
     @mock.patch('semantic_release.cli.version', return_value=False)
-    def test_publish_should_do_nothing(self, mock_version, mock_push, mock_upload):
+    def test_publish_should_do_nothing(self, mock_version, mock_push, mock_upload, mock_log):
         result = self.runner.invoke(main, ['publish'])
-        self.assertEqual(result.exit_code, 0)
         mock_version.assert_called_once_with(noop=False, force_level=None)
         self.assertFalse(mock_push.called)
         self.assertFalse(mock_upload.called)
+        self.assertFalse(mock_log.called)
+        self.assertEqual(result.exit_code, 0)
 
+    @mock.patch('semantic_release.cli.post_changelog')
     @mock.patch('semantic_release.cli.upload_to_pypi')
     @mock.patch('semantic_release.cli.push_new_version')
     @mock.patch('semantic_release.cli.version', return_value=True)
-    def test_publish_should_call_functions(self, mock_version, mock_push, mock_upload):
+    @mock.patch('semantic_release.cli.markdown_changelog', lambda *x, **y: 'CHANGES')
+    @mock.patch('semantic_release.cli.get_new_version', lambda *x: '2.0.0')
+    @mock.patch('semantic_release.cli.check_token', lambda: True)
+    def test_publish_should_call_functions(self, mock_version, mock_push, mock_upload, mock_log):
         result = self.runner.invoke(main, ['publish'])
-        self.assertEqual(result.exit_code, 0)
         mock_version.assert_called_once_with(noop=False, force_level=None)
         mock_push.assert_called_once_with()
         mock_upload.assert_called_once_with()
+        mock_log.assert_called_once_with('relekang', 'python-semantic-release', '2.0.0', 'CHANGES')
+        self.assertEqual(result.exit_code, 0)
+
+    @mock.patch('semantic_release.cli.changelog', return_value=True)
+    def test_changelog_should_call_functions(self, mock_changelog):
+        result = self.runner.invoke(main, ['changelog'])
+        self.assertEqual(result.exit_code, 0)
+        mock_changelog.assert_called_once_with(noop=False, force_level=None)
diff --git a/tests/test_history.py b/tests/test_history.py
index 3c8c646..84d4ed0 100644
--- a/tests/test_history.py
+++ b/tests/test_history.py
@@ -2,13 +2,19 @@ from unittest import TestCase
 
 import semantic_release
 from semantic_release.history import evaluate_version_bump, get_current_version, get_new_version
+from semantic_release.history.logs import generate_changelog, markdown_changelog
 
 from . import mock
 
-MAJOR = ':boom: Breaking changes'
-MINOR = ':sparkles: Add awesome feature'
-PATCH = ':bug: Fix the annoying bug'
-NO_TAG = 'Fix docs'
+MAJOR = 'feat(x): Add super-feature\n\nBREAKING CHANGE: Uses super-feature as default instead of ' \
+        'dull-feature.'
+MAJOR2 = 'feat(x): Add super-feature\n\nSome explanation\n\n' \
+         'BREAKING CHANGE: Uses super-feature as default instead of ' \
+         'dull-feature.'
+MINOR = 'feat(x): Add non-breaking super-feature'
+PATCH = 'fix(x): Fix bug in super-feature'
+NO_TAG = 'docs(x): Add documentation for super-feature'
+UNKNOWN_STYLE = 'random commits are the worst'
 
 ALL_KINDS_OF_COMMIT_MESSAGES = [MINOR, MAJOR, MINOR, PATCH]
 MINOR_AND_PATCH_COMMIT_MESSAGES = [MINOR, PATCH]
@@ -18,21 +24,22 @@ MAJOR_LAST_RELEASE_MINOR_AFTER = [MINOR, '1.1.0', MAJOR]
 
 class EvaluateVersionBumpTest(TestCase):
     def test_major(self):
-        with mock.patch('semantic_release.history.get_commit_log',
+        with mock.patch('semantic_release.history.logs.get_commit_log',
                         lambda: ALL_KINDS_OF_COMMIT_MESSAGES):
             self.assertEqual(evaluate_version_bump('0.0.0'), 'major')
 
     def test_minor(self):
-        with mock.patch('semantic_release.history.get_commit_log',
+        with mock.patch('semantic_release.history.logs.get_commit_log',
                         lambda: MINOR_AND_PATCH_COMMIT_MESSAGES):
             self.assertEqual(evaluate_version_bump('0.0.0'), 'minor')
 
     def test_patch(self):
-        with mock.patch('semantic_release.history.get_commit_log', lambda: PATCH_COMMIT_MESSAGES):
+        with mock.patch('semantic_release.history.logs.get_commit_log',
+                        lambda: PATCH_COMMIT_MESSAGES):
             self.assertEqual(evaluate_version_bump('0.0.0'), 'patch')
 
     def test_nothing_if_no_tag(self):
-        with mock.patch('semantic_release.history.get_commit_log', lambda: ['', '...']):
+        with mock.patch('semantic_release.history.logs.get_commit_log', lambda: ['', '...']):
             self.assertIsNone(evaluate_version_bump('0.0.0'))
 
     def test_force(self):
@@ -41,21 +48,21 @@ class EvaluateVersionBumpTest(TestCase):
         self.assertEqual(evaluate_version_bump('0.0.0', 'patch'), 'patch')
 
     def test_should_account_for_commits_earlier_than_last_commit(self):
-        with mock.patch('semantic_release.history.get_commit_log',
+        with mock.patch('semantic_release.history.logs.get_commit_log',
                         lambda: MAJOR_LAST_RELEASE_MINOR_AFTER):
             self.assertEqual(evaluate_version_bump('1.1.0'), 'minor')
 
     @mock.patch('semantic_release.history.config.getboolean', lambda *x: True)
-    @mock.patch('semantic_release.history.get_commit_log', lambda: [NO_TAG])
+    @mock.patch('semantic_release.history.logs.get_commit_log', lambda: [NO_TAG])
     def test_should_patch_without_tagged_commits(self):
         self.assertEqual(evaluate_version_bump('1.1.0'), 'patch')
 
     @mock.patch('semantic_release.history.config.getboolean', lambda *x: False)
-    @mock.patch('semantic_release.history.get_commit_log', lambda: [NO_TAG])
+    @mock.patch('semantic_release.history.logs.get_commit_log', lambda: [NO_TAG])
     def test_should_return_none_without_tagged_commits(self):
         self.assertIsNone(evaluate_version_bump('1.1.0'))
 
-    @mock.patch('semantic_release.history.get_commit_log', lambda: [])
+    @mock.patch('semantic_release.history.logs.get_commit_log', lambda: [])
     def test_should_return_none_without_commits(self):
         """
         Make sure that we do not release if there are no commits since last release.
@@ -67,6 +74,41 @@ class EvaluateVersionBumpTest(TestCase):
             self.assertIsNone(evaluate_version_bump('1.1.0'))
 
 
+class GenerateChangelogTests(TestCase):
+    def test_should_generate_all_sections(self):
+        with mock.patch('semantic_release.history.logs.get_commit_log',
+                        lambda: ALL_KINDS_OF_COMMIT_MESSAGES + [MAJOR2, UNKNOWN_STYLE]):
+            changelog = generate_changelog('0.0.0')
+            self.assertIn('feature', changelog)
+            self.assertIn('fix', changelog)
+            self.assertIn('documentation', changelog)
+            self.assertIn('breaking', changelog)
+            self.assertGreater(len(changelog['feature']), 0)
+            self.assertGreater(len(changelog['fix']), 0)
+            self.assertGreater(len(changelog['breaking']), 0)
+
+    def test_should_only_read_until_given_version(self):
+        with mock.patch('semantic_release.history.logs.get_commit_log',
+                        lambda: MAJOR_LAST_RELEASE_MINOR_AFTER):
+            changelog = generate_changelog('1.1.0')
+            self.assertGreater(len(changelog['feature']), 0)
+            self.assertEqual(len(changelog['fix']), 0)
+            self.assertEqual(len(changelog['documentation']), 0)
+            self.assertEqual(len(changelog['breaking']), 0)
+
+    def test_should_skip_style_changes(self):
+        with mock.patch('semantic_release.history.logs.get_commit_log',
+                        lambda: PATCH_COMMIT_MESSAGES + ['style(x): change x']):
+            changelog = generate_changelog('0.0.0')
+            self.assertNotIn('style', changelog)
+
+    def test_should_skip_chore_changes(self):
+        with mock.patch('semantic_release.history.logs.get_commit_log',
+                        lambda: PATCH_COMMIT_MESSAGES + ['chore(x): change x']):
+            changelog = generate_changelog('0.0.0')
+            self.assertNotIn('chore', changelog)
+
+
 class GetCurrentVersionTests(TestCase):
     def test_should_return_correct_version(self):
         self.assertEqual(get_current_version(), semantic_release.__version__)
@@ -92,3 +134,58 @@ class GetNewVersionTests(TestCase):
 
     def test_None_bump(self):
         self.assertEqual(get_new_version('1.0.0', None), '1.0.0')
+
+
+class MarkdownChangelogTests(TestCase):
+    def test_should_output_all_sections(self):
+        markdown = markdown_changelog('0', {
+            'refactor': ['Refactor super-feature'],
+            'breaking': ['Uses super-feature as default instead of dull-feature.'],
+            'feature': ['Add non-breaking super-feature', 'Add super-feature'],
+            'fix': ['Fix bug in super-feature'],
+            'documentation': ['Document super-feature']
+        })
+        self.assertEqual(
+            markdown,
+            '\n'
+            '### Feature\n'
+            '* Add non-breaking super-feature\n'
+            '* Add super-feature\n'
+            '\n'
+            '### Fix\n'
+            '* Fix bug in super-feature\n'
+            '\n'
+            '### Breaking\n'
+            '* Uses super-feature as default instead of dull-feature.\n'
+            '\n'
+            '### Documentation\n'
+            '* Document super-feature\n'
+        )
+
+    def test_should_not_include_empty_sections(self):
+        self.assertEqual(
+            markdown_changelog(
+                '1.0.1',
+                {'refactor': [], 'breaking': [], 'feature': [], 'fix': [], 'documentation': []},
+            ),
+            ''
+        )
+
+    def test_should_output_heading(self):
+        self.assertIn(
+            '## v1.0.1\n',
+            markdown_changelog(
+                '1.0.1',
+                {'refactor': [], 'breaking': [], 'feature': [], 'fix': [], 'documentation': []},
+                header=True
+            )
+        )
+
+    def test_should_not_output_heading(self):
+        self.assertNotIn(
+            'v1.0.1',
+            markdown_changelog(
+                '1.0.1',
+                {'refactor': [], 'breaking': [], 'feature': [], 'fix': [], 'documentation': []},
+            )
+        )
diff --git a/tests/test_hvcs.py b/tests/test_hvcs.py
index 08f7a97..b7a79d4 100644
--- a/tests/test_hvcs.py
+++ b/tests/test_hvcs.py
@@ -4,7 +4,7 @@ from unittest import TestCase
 import responses
 
 from semantic_release.errors import ImproperConfigurationError
-from semantic_release.hvcs import Github, check_build_status, get_hvcs
+from semantic_release.hvcs import Github, check_build_status, check_token, get_hvcs
 
 from . import mock
 
@@ -22,6 +22,14 @@ class HCVSHelperTests(TestCase):
         check_build_status('owner', 'name', 'ref')
         mock_github_helper.assert_called_once_with('owner', 'name', 'ref')
 
+    @mock.patch('semantic_release.hvcs.Github.token', lambda: 'token')
+    def test_check_token_should_return_true(self):
+        self.assertTrue(check_token())
+
+    @mock.patch('semantic_release.hvcs.Github.token', lambda: None)
+    def test_check_token_should_return_false(self):
+        self.assertFalse(check_token())
+
 
 class GithubCheckBuildStatusTests(TestCase):
     url = ('https://api.github.com/repos/relekang/rmoq/commits/'
@@ -66,3 +74,41 @@ class GithubCheckBuildStatusTests(TestCase):
         )
         self.assertTrue(Github.check_build_status('relekang', 'rmoq',
                                                   '6dcb09b5b57875f334f61aebed695e2e4193db5e'))
+
+
+class GithubReleaseTests(TestCase):
+    url = 'https://api.github.com/repos/relekang/rmoq/releases'
+
+    @responses.activate
+    @mock.patch('semantic_release.hvcs.Github.token', lambda: 'super-token')
+    def test_should_post_changelog(self):
+        def request_callback(request):
+            payload = json.loads(request.body)
+            self.assertEqual(payload['tag_name'], 'v1.0.0')
+            self.assertEqual(payload['body'], 'text')
+            self.assertEqual(payload['draft'], False)
+            self.assertEqual(payload['prerelease'], False)
+            self.assertIn('?access_token=super-token', request.url)
+
+            return 201, {}, json.dumps({})
+
+        responses.add_callback(
+            responses.POST,
+            self.url,
+            callback=request_callback,
+            content_type='application/json'
+        )
+        status, payload = Github.post_release_changelog('relekang', 'rmoq', '1.0.0', 'text')
+        self.assertTrue(status)
+        self.assertEqual(payload, {})
+
+    @responses.activate
+    def test_should_return_false_status_if_it_failed(self):
+        responses.add(
+            responses.POST,
+            self.url,
+            status=400,
+            body='{}',
+            content_type='application/json'
+        )
+        self.assertFalse(Github.post_release_changelog('relekang', 'rmoq', '1.0.0', 'text')[0])
diff --git a/tests/test_parsers.py b/tests/test_parsers.py
new file mode 100644
index 0000000..db295b8
--- /dev/null
+++ b/tests/test_parsers.py
@@ -0,0 +1,69 @@
+from unittest import TestCase
+
+from semantic_release.errors import UnknownCommitMessageStyle
+from semantic_release.history.parser_angular import parse_commit_message as angular_parser
+
+
+class AngularCommitParserTests(TestCase):
+
+    text = 'This is an long explanatory part of a commit message. It should give ' \
+           'some insight to the fix this commit adds to the codebase.'
+    footer = 'Closes #400'
+
+    def test_parser_raises_unknown_message_style(self):
+        self.assertRaises(UnknownCommitMessageStyle, angular_parser, '')
+
+    def test_parser_return_correct_bump_level(self):
+        self.assertEqual(
+            angular_parser('feat(parsers): Add new parser pattern\n\nBREAKING CHANGE:')[0],
+            3
+        )
+        self.assertEqual(
+            angular_parser('feat(parsers): Add new parser pattern\n\n'
+                           'New pattern is awesome\n\nBREAKING CHANGE:')[0],
+            3
+        )
+        self.assertEqual(angular_parser('feat(parser): Add emoji parser')[0], 2)
+        self.assertEqual(angular_parser('fix(parser): Fix regex in angular parser')[0], 1)
+        self.assertEqual(angular_parser('test(parser): Add a test for angular parser')[0], 0)
+
+    def test_parser_return_type_from_commit_message(self):
+        self.assertEqual(angular_parser('feat(parser): ...')[1], 'feature')
+        self.assertEqual(angular_parser('fix(parser): ...')[1], 'fix')
+        self.assertEqual(angular_parser('test(parser): ...')[1], 'test')
+        self.assertEqual(angular_parser('docs(parser): ...')[1], 'documentation')
+        self.assertEqual(angular_parser('style(parser): ...')[1], 'style')
+        self.assertEqual(angular_parser('refactor(parser): ...')[1], 'refactor')
+        self.assertEqual(angular_parser('chore(parser): ...')[1], 'chore')
+
+    def test_parser_return_scope_from_commit_message(self):
+        self.assertEqual(angular_parser('chore(parser): ...')[2], 'parser')
+        self.assertEqual(angular_parser('chore(a part): ...')[2], 'a part')
+        self.assertEqual(angular_parser('chore(a_part): ...')[2], 'a_part')
+        self.assertEqual(angular_parser('chore(a-part): ...')[2], 'a-part')
+
+    def test_parser_return_subject_from_commit_message(self):
+        self.assertEqual(
+            angular_parser('feat(parser): Add emoji parser')[3][0],
+            'Add emoji parser'
+        )
+        self.assertEqual(
+            angular_parser('fix(parser): Fix regex in angular parser')[3][0],
+            'Fix regex in angular parser'
+        )
+        self.assertEqual(
+            angular_parser('test(parser): Add a test for angular parser')[3][0],
+            'Add a test for angular parser'
+        )
+
+    def test_parser_return_text_from_commit_message(self):
+        self.assertEqual(
+            angular_parser('fix(parser): Fix regex in an parser\n\n{}'.format(self.text))[3][1],
+            self.text
+        )
+
+    def test_parser_return_footer_from_commit_message(self):
+        self.assertEqual(
+            angular_parser('fix(tox): Fix env \n\n{t.text}\n\n{t.footer}'.format(t=self))[3][2],
+            self.footer
+        )
diff --git a/tests/test_settings.py b/tests/test_settings.py
index 439518d..76e0bdc 100644
--- a/tests/test_settings.py
+++ b/tests/test_settings.py
@@ -1,6 +1,8 @@
 from unittest import TestCase
 
-from semantic_release.settings import _config
+from semantic_release.errors import ImproperConfigurationError
+from semantic_release.history import parser_angular
+from semantic_release.settings import _config, current_commit_parser
 
 from . import mock
 
@@ -24,3 +26,14 @@ class ConfigTests(TestCase):
         self.assertFalse(config.getboolean('semantic_release', 'patch_without_tag'))
         self.assertFalse(config.getboolean('semantic_release', 'check_build_status'))
         self.assertEqual(config.get('semantic_release', 'hvcs'), 'github')
+
+    @mock.patch('semantic_release.settings.config.get', lambda *x: 'nonexistent.parser')
+    def test_current_commit_parser_should_raise_error_if_parser_module_do_not_exist(self):
+        self.assertRaises(ImproperConfigurationError, current_commit_parser)
+
+    @mock.patch('semantic_release.settings.config.get', lambda *x: 'semantic_release.not_a_parser')
+    def test_current_commit_parser_should_raise_error_if_parser_do_not_exist(self):
+        self.assertRaises(ImproperConfigurationError, current_commit_parser)
+
+    def test_current_commit_parser_should_return_correct_parser(self):
+        self.assertEqual(current_commit_parser(), parser_angular.parse_commit_message)