Skip to content

Commit

Permalink
GitCommitBear.py: Require issue ref in commit body
Browse files Browse the repository at this point in the history
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 coala#1112
  • Loading branch information
nkprince007 committed Jan 10, 2017
1 parent 57dbcc4 commit 8d4347d
Show file tree
Hide file tree
Showing 2 changed files with 244 additions and 2 deletions.
112 changes: 111 additions & 1 deletion bears/vcs/git/GitCommitBear.py
Expand Up @@ -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
Expand All @@ -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):
Expand All @@ -40,14 +49,38 @@ 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(
FunctionMetadata.from_function(
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):
"""
Expand All @@ -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:
Expand All @@ -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,
Expand Down Expand Up @@ -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'))
134 changes: 133 additions & 1 deletion tests/vcs/git/GitCommitBearTest.py
Expand Up @@ -95,12 +95,14 @@ def test_get_metadata(self):
metadata = GitCommitBear.get_metadata()
self.assertEqual(
metadata.name,
"<Merged signature of 'run', 'check_shortlog', 'check_body'>")
"<Merged signature of 'run', 'check_shortlog', 'check_body'"
", 'check_issue'>")

# 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
Expand Down Expand Up @@ -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.')
Expand Down

0 comments on commit 8d4347d

Please sign in to comment.