From 8d4347d24d3c355bd81960031b0999daad7da2a0 Mon Sep 17 00:00:00 2001 From: Naveen Kumar Sangi Date: Fri, 30 Dec 2016 10:55:05 +0530 Subject: [PATCH] GitCommitBear.py: Require issue ref in commit body By default, this ensures that the git commit contains a short reference to an issue. If keyword regex is chosen, it verifies the presence of matching expression. Fixes https://github.com/coala/coala-bears/issues/1112 --- bears/vcs/git/GitCommitBear.py | 112 +++++++++++++++++++++++- tests/vcs/git/GitCommitBearTest.py | 134 ++++++++++++++++++++++++++++- 2 files changed, 244 insertions(+), 2 deletions(-) diff --git a/bears/vcs/git/GitCommitBear.py b/bears/vcs/git/GitCommitBear.py index 07d7595675..0ba1bd3ac9 100644 --- a/bears/vcs/git/GitCommitBear.py +++ b/bears/vcs/git/GitCommitBear.py @@ -3,6 +3,8 @@ import shutil import os +from urllib.parse import urlparse + from coalib.bears.GlobalBear import GlobalBear from dependency_management.requirements.PipRequirement import PipRequirement from coala_utils.ContextManagers import change_directory @@ -20,6 +22,13 @@ class GitCommitBear(GlobalBear): LICENSE = 'AGPL-3.0' ASCIINEMA_URL = 'https://asciinema.org/a/e146c9739ojhr8396wedsvf0d' CAN_DETECT = {'Formatting'} + SUPPORTED_HOSTS = { + 'github': {'close', 'closes', 'closed', 'resolve', + 'resolves', 'resolved', 'fix', 'fixes', 'fixed'}, + 'gitlab': {'close', 'closes', 'closed', 'closing', + 'resolve', 'resolves', 'resolved', 'resolving', + 'fix', 'fixes', 'fixed', 'fixing'} + } @classmethod def check_prerequisites(cls): @@ -40,6 +49,12 @@ def get_body_checks_metadata(cls): cls.check_body, omit={'self', 'body'}) + @classmethod + def get_issue_checks_metadata(cls): + return FunctionMetadata.from_function( + cls.check_issue, + omit={'self', 'body'}) + @classmethod def get_metadata(cls): return FunctionMetadata.merge( @@ -47,7 +62,25 @@ def get_metadata(cls): cls.run, omit={'self', 'dependency_results'}), cls.get_shortlog_checks_metadata(), - cls.get_body_checks_metadata()) + cls.get_body_checks_metadata(), + cls.get_issue_checks_metadata()) + + @staticmethod + def get_host_from_remotes(): + """ + Retrieve the first host from the list of git remotes. + """ + remotes, _ = run_shell_command('git config --get-regex url') + remotes = [url.split()[-1] for url in remotes.splitlines()] + if len(remotes) == 0: + return None + + url = remotes[0] + if 'git@' in url: + netloc = re.findall(r'@(\S+):', url)[0] + else: + netloc = urlparse(url)[1] + return netloc.split('.')[0] def run(self, allow_empty_commit_message: bool = False, **kwargs): """ @@ -66,6 +99,7 @@ def run(self, allow_empty_commit_message: bool = False, **kwargs): self.err('git:', repr(stderr)) return + body = stdout.rstrip('\n') stdout = stdout.rstrip('\n').splitlines() if len(stdout) == 0: @@ -79,6 +113,9 @@ def run(self, allow_empty_commit_message: bool = False, **kwargs): yield from self.check_body( stdout[1:], **self.get_body_checks_metadata().filter_parameters(kwargs)) + yield from self.check_issue( + body, + **self.get_issue_checks_metadata().filter_parameters(kwargs)) def check_shortlog(self, shortlog, shortlog_length: int=50, @@ -203,3 +240,76 @@ def check_body(self, body, yield Result(self, 'Body of HEAD commit contains too long lines. ' 'Commit body lines should not exceed {} ' 'characters.'.format(body_line_length)) + + def check_issue(self, body, + body_close_issue: bool=False, + body_close_issue_full_url: bool=False, + body_close_issue_on_last_line: bool=False): + """ + Check for matching issue related references and URLs. + + :param body: + Body of the commit message of HEAD. + :param body_close_issue: + Whether to check for the presence of issue close reference + within the commit body by retrieving host information from git + configuration. GitHub and GitLab support auto closing issues with + commit messages. Checks for matching keywords in the commit body. + By default, if none of ``body_close_issue_full_url`` and + ``body_close_issue_on_last_line`` are enabled, checks for presence + of short references like ``closes #213``. Otherwise behaves + according to other chosen flags. + More on keywords follows. + [GitHub keywords](https://help.github.com/articles/closing-issues-via-commit-messages/) + [GitLab keywords](https://docs.gitlab.com/ce/user/project/issues/automatic_issue_closing.html) + :param body_close_issue_full_url: + Checks the presence of issue close reference with a full URL + related to some issue. Works along with ``body_close_issue``. + :param body_close_issue_on_last_line: + When enabled, checks for issue close reference presence on the + last line of the commit body. Works along with + ``body_close_issue``. + """ + if not body_close_issue: + return + + host = self.get_host_from_remotes() + if host not in self.SUPPORTED_HOSTS: + return + + if not body_close_issue_on_last_line: + result_message = ('No {} reference found in the body at HEAD ' + 'commit.') + else: + body = body.splitlines()[-1] + result_message = ('No {} reference found in the body at HEAD ' + 'commit in the last line.') + + keywords = self.SUPPORTED_HOSTS[host] + good_ref = False + body = body.lower() + + if not any(key in body for key in keywords): + yield Result(self, result_message.format('issue')) + return + + if not body_close_issue_full_url: + if not re.search('#[1-9][0-9]*', body): + yield Result(self, result_message.format('short issue')) + return + + for match in re.findall(r'https?://\S+', body): + netloc, path = urlparse(match)[1:3] + if not host in netloc or not '/issues/' in path: + continue + issue_num = path.split('/')[-1] + try: + issue_num = int(issue_num) + good_ref = True + except ValueError: + yield Result(self, 'Invalid issue number present in the ' + 'body at HEAD commit: %s' % issue_num) + return + + if not good_ref: + yield Result(self, result_message.format('issue')) diff --git a/tests/vcs/git/GitCommitBearTest.py b/tests/vcs/git/GitCommitBearTest.py index a9997d4872..7664ffe256 100644 --- a/tests/vcs/git/GitCommitBearTest.py +++ b/tests/vcs/git/GitCommitBearTest.py @@ -95,12 +95,14 @@ def test_get_metadata(self): metadata = GitCommitBear.get_metadata() self.assertEqual( metadata.name, - "") + "") # Test if at least one parameter of each signature is used. self.assertIn('allow_empty_commit_message', metadata.optional_params) self.assertIn('shortlog_length', metadata.optional_params) self.assertIn('body_line_length', metadata.optional_params) + self.assertIn('body_close_issue', metadata.optional_params) def test_git_failure(self): # In this case use a reference to a non-existing commit, so just try @@ -267,6 +269,136 @@ def test_body_checks(self): []) self.assertTrue(self.msg_queue.empty()) + def test_check_issue(self): + # Commit with no remotes configured + self.git_commit('Shortlog\n\n' + 'First line, blablablablablabla.\n' + 'Another line, blablablablablabla.\n' + 'Closes #01112') + self.assertEqual(self.run_uut(body_close_issue=True), []) + + # Adding BitBucket remote for testing + self.run_git_command('remote', 'add', 'test', + 'https://bitbucket.com/user/repo.git') + + # Unsupported Host + self.git_commit('Shortlog\n\n' + 'First line, blablablablablabla.\n' + 'Another line, blablablablablabla.\n' + 'Closes #1112') + self.assertEqual(self.run_uut( + body_close_issue=True, + body_close_issue_full_url=True), []) + + # Adding GitHub remote for testing, ssh way :P + self.run_git_command('remote', 'set-url', 'test', + 'git@github.com:user/repo.git') + + # GitHub host with an issue + self.git_commit('Shortlog\n\n' + 'First line, blablablablablabla.\n' + 'Another line, blablablablablabla.\n' + 'Fix https://github.com/user/repo/issues/1112') + self.assertEqual(self.run_uut( + body_close_issue=True, + body_close_issue_full_url=True), []) + + # No keywords and no issues... + self.git_commit('Shortlog\n\n' + 'This line is ok.\n' + 'This line is by far too long (in this case).\n' + 'This one too, blablablablablablablablabla.') + self.assertEqual(self.run_uut(body_close_issue=True,), + ['No issue reference found in the body at ' + 'HEAD commit.']) + self.assert_no_msgs() + + # Has keyword but no valid issue URL... + self.git_commit('Shortlog\n\n' + 'First line, blablablablablabla.\n' + 'Another line, blablablablablabla.\n' + 'Fix https://github.com/user/repo.git') + self.assertEqual(self.run_uut( + body_close_issue=True, + body_close_issue_full_url=True), + ['No issue reference found in the body ' + 'at HEAD commit.']) + + # GitHub host with short issue tag + self.git_commit('Shortlog\n\n' + 'First line, blablablablablabla.\n' + 'Another line, blablablablablabla.\n' + 'Fix #1112') + self.assertEqual(self.run_uut(body_close_issue=True,), []) + + # GitHub host with invalid short issue tag + self.git_commit('Shortlog\n\n' + 'First line, blablablablablabla.\n' + 'Another line, blablablablablabla.\n' + 'Fix #01112') + self.assertEqual(self.run_uut(body_close_issue=True,), + ['No short issue reference found in the body at ' + 'HEAD commit.']) + + # GitHub host with no issue reference + self.git_commit('Shortlog\n\n' + 'First line, blablablablablabla.\n' + 'Another line, blablablablablabla.\n' + 'Fix #01112') + self.assertEqual(self.run_uut( + body_close_issue=True, + body_close_issue_full_url=True), + ['No issue reference found in the ' + 'body at HEAD commit.']) + + # Adding GitLab remote for testing + self.run_git_command('remote', 'set-url', 'test', + 'git@gitlab.com:user/repo.git') + + # GitLab chosen and has an issue + self.git_commit('Shortlog\n\n' + 'First line, blablablablablabla.\n' + 'Another line, blablablablablabla.\n' + 'Closing https://gitlab.com/user/repo/issues/1112') + self.assertEqual(self.run_uut( + body_close_issue=True, + body_close_issue_full_url=True), []) + + # Invalid issue number in URL + self.git_commit('Shortlog\n\n' + 'First line, blablablablablabla.\n' + 'Another line, blablablablablabla.\n' + 'Closing https://gitlab.com/user/repo/issues/notnum') + self.assertEqual(self.run_uut( + body_close_issue=True, + body_close_issue_full_url='full_ref'), + ['Invalid issue number present in the body ' + 'at HEAD commit: notnum']) + self.assert_no_msgs() + + # Has an invalid URL + self.git_commit('Shortlog\n\n' + 'First line, blablablablablabla.\n' + 'Another line, blablablablablabla.\n' + 'Fix http://google.com/issues/hehehe') + self.assertEqual(self.run_uut( + body_close_issue=True, + body_close_issue_full_url=True), + ['No issue reference found in ' + 'the body at HEAD commit.']) + + # Last line enforce URL + self.git_commit('Shortlog\n\n' + 'First line, blablablablablabla.\n' + 'Fix http://gitlab.com/user/repo/issues/1112\n' + 'Another line, blablablablablabla.\n') + self.assertEqual(self.run_uut( + body_close_issue=True, + body_close_issue_full_url=True, + body_close_issue_on_last_line=True), + ['No issue reference found in the body ' + 'at HEAD commit in the last line.']) + def test_different_path(self): no_git_dir = mkdtemp() self.git_commit('Add a very long shortlog for a bad project history.')