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 9c1a0bf commit fa3eeee
Show file tree
Hide file tree
Showing 2 changed files with 256 additions and 2 deletions.
107 changes: 106 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,12 @@ class GitCommitBear(GlobalBear):
LICENSE = 'AGPL-3.0'
ASCIINEMA_URL = 'https://asciinema.org/a/e146c9739ojhr8396wedsvf0d'
CAN_DETECT = {'Formatting'}
SUPPORTED_HOSTS = {
'github': (r'(?:[Cc]los(?:e[sd]?)|[Rr]esolv(?:e[sd]?)'
'|[Ff]ix(?:e[sd]?)?)\s+'),
'gitlab': (r'(?:[Cc]los(?:e[sd]?|ing)|[Rr]esolv(?:e[sd]?|ing)'
'|[Ff]ix(?:e[sd]|ing)?)\s+')
}

@classmethod
def check_prerequisites(cls):
Expand All @@ -40,14 +48,40 @@ 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 '^remote.*.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 Down Expand Up @@ -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(
'\n'.join(stdout[1:]),
**self.get_issue_checks_metadata().filter_parameters(kwargs))

def check_shortlog(self, shortlog,
shortlog_length: int=50,
Expand Down Expand Up @@ -203,3 +240,71 @@ 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](https://help.github.com/articles/closing-issues-via-commit-messages/)
[GitLab](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 body_close_issue_on_last_line:
body = body.splitlines()[-1]
result_message = ('No {} reference found in the body at HEAD '
'commit in the last line.')
else:
result_message = ('No {} reference found in the body at HEAD '
'commit.')

if body_close_issue_full_url:
result_info = 'full issue'
ref_regex = r'https?://\S*/issues/[1-9][0-9]*'
help_regex = r'https?://{}\S*'.format(host)
else:
result_info = 'issue'
ref_regex = r'#[1-9][0-9]*'
help_regex = r'#\S*'

conj_regex = r'(%s\s*(?:(?:,|and)\s*%s)*)'
keyword_regex = self.SUPPORTED_HOSTS[host]

matches = re.findall(keyword_regex
+ conj_regex % (help_regex, help_regex), body)
issue_regex = re.compile(conj_regex % (ref_regex, ref_regex))

if len(matches) == 0:
yield Result(self, result_message.format(result_info))
return

for match in matches:
if not issue_regex.match(match):
yield Result(self, 'Invalid issue number at %s' % match)
151 changes: 150 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,153 @@ 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 - Bitbucket
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/usr/repo/issues/1112\n'
'and resolves https://github.com/usr/repo/issues/1312')
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),
['Invalid issue number at '
'https://github.com/user/repo.git'])
self.assert_no_msgs()

# 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,),
['Invalid issue number at #01112'])
self.assert_no_msgs()

# GitHub host with no full 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 full issue reference found in the '
'body at HEAD commit.'])
self.assert_no_msgs()

# 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\n'
'and https://gitlab.com/user/repo/issues/1113')
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\n'
'and https://gitlab.com/user/repo/issues/notnumagain')
self.assertEqual(self.run_uut(
body_close_issue=True,
body_close_issue_full_url=True),
['Invalid issue number at '
'https://gitlab.com/user/repo/issues/notnum\nand '
'https://gitlab.com/user/repo/issues/notnumagain'])
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 full issue reference found in '
'the body at HEAD commit.'])
self.assert_no_msgs()

# One of the references is broken
self.git_commit('Shortlog\n\n'
'First line, blablablablablabla.\n'
'Another line, blablablablablabla.\n'
'Resolve #11 and close #notnum')
self.assertEqual(self.run_uut(body_close_issue=True,),
['Invalid issue number at #notnum'])
self.assert_no_msgs()

# 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 full issue reference found in the body '
'at HEAD commit in the last line.'])
self.assert_no_msgs()

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 fa3eeee

Please sign in to comment.