Repository: stuartmccoll/gitlab-changelog-generator

Issue:
Implement 'fixed bugs' section in CHANGELOG.md
The [GitLab API](https://docs.gitlab.com/ee/api/) can be queried to find closed issues since the last tagged version of a repository. We can query this at a more granular level to determine closed issues which had a `bug` label applied to them. This feature can be used to add a new section to the `CHANGELOG.md` file which is generated by this utility, something along the lines of 'Fixed Bugs' which details those issues labelled as bugs which have been closed.

Discussion:

Patch:
diff --git a/.gitignore b/.gitignore
index aae64a7..0c9be46 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,6 @@
+# Application specific
+CHANGELOG_generated.md
+
 # Byte-compiled / optimized / DLL files
 __pycache__/
 *.py[cod]
@@ -24,6 +27,8 @@ wheels/
 .installed.cfg
 *.egg
 MANIFEST
+bin/
+include/
 
 # PyInstaller
 #  Usually these files are written by a python script from a template
diff --git a/.travis.yml b/.travis.yml
index d6aa6ab..881e436 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -1,13 +1,14 @@
 language: python
-python: 
+python:
   - "3.6"
   - "3.7-dev"
 
 install:
-- pip install requests
-- pip install pyre-check
-- pip install pytest-cov
+  - pip install python-dateutil
+  - pip install requests
+  - pip install pyre-check
+  - pip install pytest-cov
+  - pip install pytz
 
 script:
-- python3 -m pytest --cov-report term-missing --cov=changelog_generator
-- pyre --source-directory . check
\ No newline at end of file
+  - python3 -m pytest --cov-report term-missing --cov=changelog_generator
diff --git a/changelog_generator/calls.py b/changelog_generator/calls.py
index 18cef91..c7d57cd 100644
--- a/changelog_generator/calls.py
+++ b/changelog_generator/calls.py
@@ -44,6 +44,64 @@ def get_last_commit_date(cli_args: dict) -> str:
     )
 
 
+def get_closed_issues_for_project(cli_args: dict) -> dict:
+    """
+    Queries a specified GitLab API and returns a list containing
+    the titles and URLs of closed issues since a given date.
+    """
+    request_url = f"http://{cli_args['ip_address']}/api/v{cli_args['api_version']}/projects/{cli_args['project_group']}%2F{cli_args['project']}/issues?state=closed"
+    logger.info(
+        f"Requesting tags for project {cli_args['project']} with URL: {request_url}"
+    )
+    try:
+        response = requests.get(request_url)
+        response.raise_for_status()
+    except requests.exceptions.HTTPError as ex:
+        logger.error(
+            f"{get_commits_since_date.__name__} call to GitLab API failed with HTTPError: {ex}"
+        )
+        sys.exit(1)
+    except requests.exceptions.ConnectionError as ex:
+        logger.error(
+            f"{get_commits_since_date.__name__} call to GitLab API failed with ConnectionError: {ex}"
+        )
+        sys.exit(1)
+
+    logger.debug(response.status_code)
+    logger.debug(response.json())
+
+    return response.json()
+
+
+def get_last_tagged_release_date(cli_args: dict) -> str:
+    """
+    Queries a specified GitLab API and returns a string containing
+    the created_at date of the last tagged release.
+    """
+    request_url = f"http://{cli_args['ip_address']}/api/v{cli_args['api_version']}/projects/{cli_args['project_group']}%2F{cli_args['project']}/repository/tags"
+    logger.info(
+        f"Requesting tags for project {cli_args['project']} with URL: {request_url}"
+    )
+    try:
+        response = requests.get(request_url)
+        response.raise_for_status()
+    except requests.exceptions.HTTPError as ex:
+        logger.error(
+            f"{get_commits_since_date.__name__} call to GitLab API failed with HTTPError: {ex}"
+        )
+        sys.exit(1)
+    except requests.exceptions.ConnectionError as ex:
+        logger.error(
+            f"{get_commits_since_date.__name__} call to GitLab API failed with ConnectionError: {ex}"
+        )
+        sys.exit(1)
+
+    logger.debug(response.status_code)
+    logger.debug(response.json())
+
+    return response.json()[0]["commit"]["created_at"]
+
+
 def get_commits_since_date(date: str, cli_args: dict) -> list:
     """
     Queries a specified GitLab API and returns a JSON response containing
diff --git a/changelog_generator/generator.py b/changelog_generator/generator.py
index 24b94c0..cea4db2 100644
--- a/changelog_generator/generator.py
+++ b/changelog_generator/generator.py
@@ -1,10 +1,13 @@
 import datetime
+import dateutil.parser
 import os.path
 
 from changelog_generator.log_handlers import logger
 from changelog_generator.calls import (
-    get_last_commit_date,
+    get_closed_issues_for_project,
     get_commits_since_date,
+    get_last_commit_date,
+    get_last_tagged_release_date,
 )
 
 
@@ -12,6 +15,8 @@ def generate_changelog(cli_args: dict) -> str:
     # Get the date of the last commit
     last_commit = get_last_commit_date(cli_args)
 
+    closed_issues_since_last_tag = get_closed_issues_since_last_tag(cli_args)
+
     # Get any commits since that date
     new_commits = get_commits_since_date(last_commit, cli_args)
 
@@ -35,6 +40,15 @@ def generate_changelog(cli_args: dict) -> str:
                         for commit in new_commits
                         for y in commit["message"].split("\n")
                     ]
+
+                    if closed_issues_since_last_tag:
+                        modified_changelog.write(f"\n### Closed Issues\n")
+                        [
+                            modified_changelog.write(
+                                f"* {closed_issue['title']}"
+                            )
+                            for closed_issue in closed_issues_since_last_tag
+                        ]
                     modified_changelog.write(f"\n")
                     modified_changelog.write(original_changelog_data)
                     return "CHANGELOG.md updated successfully"
@@ -50,6 +64,13 @@ def generate_changelog(cli_args: dict) -> str:
                     for commit in new_commits
                     for y in commit["message"].split("\n")
                 ]
+
+                if closed_issues_since_last_tag:
+                    changelog.write(f"\n### Closed Issues\n")
+                    [
+                        changelog.write(f"* {closed_issue['title']}")
+                        for closed_issue in closed_issues_since_last_tag
+                    ]
             return "CHANGELOG_generated.md written successfully"
     else:
         logger.info("No CHANGELOG.md found and no CHANGELOG.md specified...")
@@ -61,5 +82,28 @@ def generate_changelog(cli_args: dict) -> str:
                 for commit in new_commits
                 for y in commit["message"].split("\n")
             ]
-
+            if closed_issues_since_last_tag:
+                changelog.write(f"\n### Closed Issues\n")
+                [
+                    changelog.write(f"* {closed_issue['title']}")
+                    for closed_issue in closed_issues_since_last_tag
+                ]
         return "New CHANGELOG.md file written successfully"
+
+
+def get_closed_issues_since_last_tag(cli_args: dict) -> list:
+    last_tagged_release_date = get_last_tagged_release_date(cli_args)
+
+    closed_issues = get_closed_issues_for_project(cli_args)
+
+    closed_issues_since_tag = []
+    for issue in closed_issues:
+        logger.info(issue)
+        if dateutil.parser.parse(issue["closed_at"]) > dateutil.parser.parse(
+            last_tagged_release_date
+        ):
+            closed_issues_since_tag.append(
+                {"closed_at": issue["closed_at"], "title": issue["title"]}
+            )
+
+    return closed_issues_since_tag


Test patch:
diff --git a/changelog_generator/tests/test_calls.py b/changelog_generator/tests/test_calls.py
index caf19f4..0024e2f 100644
--- a/changelog_generator/tests/test_calls.py
+++ b/changelog_generator/tests/test_calls.py
@@ -4,6 +4,8 @@ import unittest
 
 from changelog_generator.calls import (
     get_last_commit_date,
+    get_last_tagged_release_date,
+    get_closed_issues_for_project,
     get_commits_since_date,
 )
 
@@ -116,3 +118,56 @@ class TestCalls(unittest.TestCase):
             )
             for commit in commits
         ]
+
+    @mock.patch("changelog_generator.calls.requests.get")
+    def test_get_closed_issues_for_project(self, mock_get):
+
+        mock_get.return_value.json.return_value = [
+            {
+                "closed_at": "2018-06-10T14:01:44.000+00:00",
+                "title": "A Closed Issue",
+            }
+        ]
+
+        cli_args = {
+            "ip_address": "localhost",
+            "api_version": "4",
+            "project_group": "test-group",
+            "project": "test-project",
+            "branch_one": "release",
+            "branch_two": "master",
+            "version": "1",
+            "changelog": "N",
+        }
+
+        self.assertEqual(
+            mock_get.return_value.json.return_value,
+            get_closed_issues_for_project(cli_args),
+        )
+
+    @mock.patch("changelog_generator.calls.requests.get")
+    def test_get_last_tagged_release_date(self, mock_get):
+        mock_get.return_value.json.return_value = [
+            {
+                "commit": {
+                    "created_at": "2018-06-10T14:01:44.000+00:00",
+                    "name": "A Closed Issue",
+                }
+            }
+        ]
+
+        cli_args = {
+            "ip_address": "localhost",
+            "api_version": "4",
+            "project_group": "test-group",
+            "project": "test-project",
+            "branch_one": "release",
+            "branch_two": "master",
+            "version": "1",
+            "changelog": "N",
+        }
+
+        self.assertEqual(
+            "2018-06-10T14:01:44.000+00:00",
+            get_last_tagged_release_date(cli_args),
+        )
diff --git a/changelog_generator/tests/test_generator.py b/changelog_generator/tests/test_generator.py
index 7fd8668..c52c15d 100644
--- a/changelog_generator/tests/test_generator.py
+++ b/changelog_generator/tests/test_generator.py
@@ -2,15 +2,25 @@ import datetime
 import mock
 import unittest
 
-from changelog_generator.generator import generate_changelog
+from changelog_generator.generator import (
+    generate_changelog,
+    get_closed_issues_since_last_tag,
+)
 
 
 class TestGenerator(unittest.TestCase):
+    @mock.patch(
+        "changelog_generator.generator.get_closed_issues_since_last_tag"
+    )
     @mock.patch("os.path.isfile")
     @mock.patch("changelog_generator.generator.get_commits_since_date")
     @mock.patch("changelog_generator.generator.get_last_commit_date")
     def test_generate_changelog_existing(
-        self, mock_get_commit_date, mock_get_commits, mock_is_file
+        self,
+        mock_get_commit_date,
+        mock_get_commits,
+        mock_is_file,
+        mock_closed_issues,
     ):
         mock_is_file.return_value = True
         mock_get_commit_date.return_value = "2018-06-10T14:01:45.000000+00:00"
@@ -21,6 +31,12 @@ class TestGenerator(unittest.TestCase):
                 "message": "Test commit message",
             }
         ]
+        mock_closed_issues.return_value = [
+            {
+                "title": "A Closed Issue",
+                "closed_at": "2018-06-10T14:01:44.000+00:00",
+            }
+        ]
 
         cli_args = {
             "ip_address": "localhost",
@@ -36,13 +52,22 @@ class TestGenerator(unittest.TestCase):
         with mock.patch("builtins.open", mock.mock_open()) as mock_file:
             result = generate_changelog(cli_args)
             mock_file.assert_called_once_with(mock.ANY, "w")
-            mock_file().write.assert_called_with(
-                "* 2018-06-10 - Test commit message \n"
-            )
+            mock_file_write_calls = [
+                mock.call(
+                    f"## v1 ({datetime.datetime.now().strftime('%Y-%m-%d')})\n"
+                ),
+                mock.call("* 2018-06-10 - Test commit message \n"),
+                mock.call("\n### Closed Issues\n"),
+                mock.call("* A Closed Issue"),
+            ]
+            mock_file().write.assert_has_calls(mock_file_write_calls)
             self.assertEqual(
                 result, "CHANGELOG_generated.md written successfully"
             )
 
+    @mock.patch(
+        "changelog_generator.generator.get_closed_issues_since_last_tag"
+    )
     @mock.patch("changelog_generator.generator.datetime")
     @mock.patch("os.path.isfile")
     @mock.patch("changelog_generator.generator.get_commits_since_date")
@@ -53,6 +78,7 @@ class TestGenerator(unittest.TestCase):
         mock_get_commits,
         mock_is_file,
         mock_datetime,
+        mock_closed_issues,
     ):
         mock_datetime.datetime.now.return_value = datetime.date(2018, 6, 18)
         mock_is_file.return_value = True
@@ -64,6 +90,12 @@ class TestGenerator(unittest.TestCase):
                 "message": "Test commit message",
             }
         ]
+        mock_closed_issues.return_value = [
+            {
+                "title": "A Closed Issue",
+                "closed_at": "2018-06-10T14:01:44.000+00:00",
+            }
+        ]
 
         cli_args = {
             "ip_address": "localhost",
@@ -85,17 +117,26 @@ class TestGenerator(unittest.TestCase):
                 [
                     mock.call("## v1 (2018-06-18)\n"),
                     mock.call("* 2018-06-10 - Test commit message \n"),
+                    mock.call("\n### Closed Issues\n"),
+                    mock.call("* A Closed Issue"),
                     mock.call("\n"),
                     mock.call("Existing data"),
                 ]
             )
             self.assertEqual(result, "CHANGELOG.md updated successfully")
 
+    @mock.patch(
+        "changelog_generator.generator.get_closed_issues_since_last_tag"
+    )
     @mock.patch("os.path.isfile")
     @mock.patch("changelog_generator.generator.get_commits_since_date")
     @mock.patch("changelog_generator.generator.get_last_commit_date")
     def test_generate_changelog_new(
-        self, mock_get_commit_date, mock_get_commits, mock_is_file
+        self,
+        mock_get_commit_date,
+        mock_get_commits,
+        mock_is_file,
+        mock_closed_issues,
     ):
         mock_is_file.return_value = False
         mock_get_commit_date.return_value = "2018-06-10T14:01:45.000000+00:00"
@@ -106,6 +147,12 @@ class TestGenerator(unittest.TestCase):
                 "message": "Test commit message",
             }
         ]
+        mock_closed_issues.return_value = [
+            {
+                "title": "A Closed Issue",
+                "closed_at": "2018-06-10T14:01:44.000+00:00",
+            }
+        ]
 
         cli_args = {
             "ip_address": "localhost",
@@ -121,9 +168,77 @@ class TestGenerator(unittest.TestCase):
         with mock.patch("builtins.open", mock.mock_open()) as mock_file:
             result = generate_changelog(cli_args)
             mock_file.assert_called_once_with(mock.ANY, "w")
-            mock_file().write.assert_called_with(
-                "* 2018-06-10 - Test commit message \n"
-            )
+            mock_file_write_calls = [
+                mock.call(
+                    f"## v1 ({datetime.datetime.now().strftime('%Y-%m-%d')})\n"
+                ),
+                mock.call("* 2018-06-10 - Test commit message \n"),
+                mock.call("\n### Closed Issues\n"),
+                mock.call("* A Closed Issue"),
+            ]
+            mock_file().write.assert_has_calls(mock_file_write_calls)
             self.assertEqual(
                 result, "New CHANGELOG.md file written successfully"
             )
+
+    @mock.patch("changelog_generator.generator.get_closed_issues_for_project")
+    @mock.patch("changelog_generator.generator.get_last_tagged_release_date")
+    def test_get_closed_issues_since_last_tag(
+        self, mock_release_date, mock_closed_project_issues
+    ):
+        mock_release_date.return_value = "2018-06-10T14:01:44.000+00:00"
+        mock_closed_project_issues.return_value = [
+            {
+                "closed_at": "2050-06-10T14:01:44.000+00:00",
+                "title": "A Closed Issue",
+                "assignee": 1,
+            }
+        ]
+
+        cli_args = {
+            "ip_address": "localhost",
+            "api_version": "4",
+            "project_group": "test-group",
+            "project": "test-project",
+            "branch_one": "release",
+            "branch_two": "master",
+            "version": "1",
+            "changelog": "N",
+        }
+
+        self.assertEqual(
+            get_closed_issues_since_last_tag(cli_args),
+            [
+                {
+                    "closed_at": "2050-06-10T14:01:44.000+00:00",
+                    "title": "A Closed Issue",
+                }
+            ],
+        )
+
+    @mock.patch("changelog_generator.generator.get_closed_issues_for_project")
+    @mock.patch("changelog_generator.generator.get_last_tagged_release_date")
+    def test_get_closed_issues_since_last_tag_no_issues(
+        self, mock_release_date, mock_closed_project_issues
+    ):
+        mock_release_date.return_value = "2018-06-10T14:01:44.000+00:00"
+        mock_closed_project_issues.return_value = [
+            {
+                "closed_at": "1990-06-10T14:01:44.000+00:00",
+                "title": "A Closed Issue",
+                "assignee": 1,
+            }
+        ]
+
+        cli_args = {
+            "ip_address": "localhost",
+            "api_version": "4",
+            "project_group": "test-group",
+            "project": "test-project",
+            "branch_one": "release",
+            "branch_two": "master",
+            "version": "1",
+            "changelog": "N",
+        }
+
+        self.assertEqual(get_closed_issues_since_last_tag(cli_args), [])