blob: b464442cef4bd4641451e211bb6bfefef282f0a4 [file] [log] [blame]
#!/usr/bin/env python
#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
#
#
# Pre Commit Hook for running tests and updating JIRA
#
# Original version was copied from FLUME project.
#
import sys, os, re, urllib2, base64, subprocess, tempfile, shutil
import json
import datetime
from optparse import OptionParser
import xml.etree.ElementTree as ET
tmp_dir = None
BASE_JIRA_URL = 'https://issues.apache.org/jira'
# Write output to file
def write_file(filename, content):
with open(filename, "w") as text_file:
text_file.write(content)
# Guess branch for given versions
#
# Return None if detects that JIRA belongs to more than one branch
def sqoop_guess_branch(versions):
branch = None
for v in versions:
tmp_branch = None
if v.startswith("from/to"):
tmp_branch = "SQOOP-1367"
elif v.startswith("1.99") or v.startswith("2.0"):
tmp_branch = "sqoop2"
else:
tmp_branch = "trunk"
if not branch:
branch = tmp_branch
else:
if branch != tmp_branch:
return None
return branch
# Open remote URL
def open_url(url):
print "Opening URL: %s" % (url)
return urllib2.urlopen(url)
# Verify supported branch
def sqoop_verify_branch(branch):
return branch in ("sqoop2", "SQOOP-1082", "SQOOP-1367",)
def execute(cmd, log=True):
if log:
print "INFO: Executing %s" % (cmd)
return subprocess.call(cmd, shell=True)
def jenkins_link_for_jira(name, endpoint):
if "BUILD_URL" in os.environ:
return "[%s|%s%s]" % (name, os.environ['BUILD_URL'], endpoint)
else:
return name
def jenkins_file_link_for_jira(name, file):
return jenkins_link_for_jira(name, "artifact/patch-process/%s" % file)
def jira_request(result, url, username, password, data, headers):
request = urllib2.Request(url, data, headers)
print "INFO: URL = %s, Username = %s, data = %s, headers = %s" % (url, username, data, str(headers))
if username and password:
base64string = base64.encodestring('%s:%s' % (username, password)).replace('\n', '')
request.add_header("Authorization", "Basic %s" % base64string)
return urllib2.urlopen(request)
def jira_get_defect_html(result, defect, username, password):
url = "%s/browse/%s" % (BASE_JIRA_URL, defect)
return jira_request(result, url, username, password, None, {}).read()
def jira_get_defect(result, defect, username, password):
url = "%s/rest/api/2/issue/%s" % (BASE_JIRA_URL, defect)
return jira_request(result, url, username, password, None, {}).read()
def jira_color(level):
if level == ResultItem.INFO:
return "INFO:"
elif level == ResultItem.SUCCESS:
return "{color:green}SUCCESS:{color}"
elif level == ResultItem.ERROR:
return "{color:red}ERROR:{color}"
elif level == ResultItem.FATAL:
return "{color:red}ERROR:{color}"
elif level == ResultItem.WARNING:
return "{color:orange}WARNING:{color}"
else:
return level
def jira_generate_comment(result, branch):
body = [ "Testing file [%s|%s] against branch %s took %s." % (result.attachment.split('/')[-1] , result.attachment, branch, datetime.datetime.now() - result.start_time) ]
body += [ "" ]
if result.overall == "+1":
body += [ "{color:green}Overall:{color} +1 all checks pass" ]
else:
body += [ "{color:red}Overall:{color} -1 due to an error(s), see details below:" ]
body += [ "" ]
for item in result._items:
body += [ "%s %s" % (jira_color(item.level), item.message.replace("\n", "\\n")) ]
for bullet in item.bullets:
body += [ "* %s" % bullet ]
if len(item.bullets) > 0:
body += [ "\\n" ]
if "BUILD_URL" in os.environ:
body += [ "" ]
body += [ "Console output is available %s." % (jenkins_link_for_jira("here", "console")) ]
body += [ "" ]
body += [ "This message is automatically generated." ]
return "\\n".join(body)
def jira_post_comment(result, defect, branch, username, password):
url = "%s/rest/api/2/issue/%s/comment" % (BASE_JIRA_URL, defect)
# Generate body for the comment and save it to a file
body = jira_generate_comment(result, branch)
write_file("%s/jira-comment.txt" % output_dir, body.replace("\\n", "\n"))
# Send the comment to the JIRA
body = "{\"body\": \"%s\"}" % body
headers = {'Content-Type' : 'application/json'}
response = jira_request(result, url, username, password, body, headers)
body = response.read()
if response.code != 201:
msg = """Request for %s failed:
URL = '%s'
Code = '%d'
Comment = '%s'
Response = '%s'
""" % (defect, url, response.code, comment, body)
print "FATAL: %s" % (msg)
sys.exit(1)
# hack (from hadoop) but REST api doesn't list attachments?
def jira_get_attachment(result, defect, username, password):
html = jira_get_defect_html(result, defect, username, password)
pattern = "(/secure/attachment/[0-9]+/(bug)?%s[0-9\-]*((\.|-)v?[0-9]+)?\.(patch|txt|patch\.txt))" % (re.escape(defect))
matches = []
for match in re.findall(pattern, html, re.IGNORECASE):
matches += [ match[0] ]
if matches:
matches.sort()
return "%s%s" % (BASE_JIRA_URL, matches.pop())
return None
# Get versions from JIRA JSON object
def json_get_version(json):
versions = []
# Load affectedVersion field
for version in json.get("fields").get("versions"):
versions = versions + [version.get("name")]
# Load fixVersion field
for version in json.get("fields").get("fixVersions"):
versions = versions + [version.get("name")]
return versions
def git_checkout(result, branch):
if not branch:
result.fatal("Branch wasn't specified nor was correctly guessed")
return
if execute("git checkout %s" % (branch)) != 0:
result.fatal("git checkout %s failed" % branch)
if execute("git clean -d -f") != 0:
result.fatal("git clean failed")
if execute("git reset --hard HEAD") != 0:
result.fatal("git reset failed")
if execute("git fetch origin") != 0:
result.fatal("git fetch failed")
if execute("git merge --ff-only origin/%s" % (branch)):
result.fatal("git merge failed")
def git_apply(result, cmd, patch_file, strip, output_dir):
output_file = "%s/apply.txt" % (output_dir)
rc = execute("%s -p%s < %s 1>%s 2>&1" % (cmd, strip, patch_file, output_file))
output = ""
if os.path.exists(output_file):
with open(output_file) as fh:
output = fh.read()
if rc == 0:
if output:
result.warning("Patch applied, but there has been warnings:\n{code}%s{code}\n" % (output))
else:
result.success("Patch applied correctly")
else:
result.fatal("Failed to apply patch (exit code %d):\n{code}%s{code}\n" % (rc, output))
def static_test(result, patch_file, output_dir):
output_file = "%s/static-test.txt" % (output_dir)
rc = execute("grep '^+++.*/test' %s 1>%s 2>&1" % (patch_file, output_file))
if rc == 0:
result.success("Patch add/modify test case")
else:
result.error("Patch does not add/modify any test case")
def mvn_clean(result, output_dir):
rc = execute("mvn clean 1>%s/clean.txt 2>&1" % output_dir)
if rc == 0:
result.success("Clean was successful")
else:
result.fatal("Failed to clean project (exit code %d, %s)" % (rc, jenkins_file_link_for_jira("report", "clean.txt")))
def mvn_rat(result, output_dir):
rc = execute("mvn apache-rat:check 1>%s/rat.txt 2>&1" % output_dir)
if rc == 0:
result.success("License check passed")
else:
incorrect_files = []
for path in list(find_all_files(".")):
file_name = os.path.basename(path)
if file_name == "rat.txt":
fd = open(path)
for line in fd:
if "!?????" in line:
matcher = re.search("\!\?\?\?\?\? (.*)$", line)
if matcher:
incorrect_files += [ "{{%s}}" % (matcher.groups()[0]) ]
fd.close()
result.error("Failed to run license check (exit code %d, %s)" % (rc, jenkins_file_link_for_jira("report", "rat.txt")), set(incorrect_files))
def mvn_install(result, output_dir):
rc = execute("mvn install -DskipTests 1>%s/install.txt 2>&1" % output_dir)
if rc == 0:
result.success("Patch compiled")
else:
result.fatal("failed to build with patch (exit code %d, %s)" % (rc, jenkins_file_link_for_jira("report", "install.txt")))
def find_all_files(top, fileRegExp=".*", dirRegExp=".*"):
for root, dirs, files in os.walk(top):
for f in files:
if re.search(fileRegExp, f) and re.search(dirRegExp, root):
yield os.path.join(root, f)
def mvn_test(result, output_dir):
run_mvn_test("test site", "unit", result, output_dir) # Will run cobertura and findbugs as well
def mvn_integration(result, output_dir):
run_mvn_test("integration-test -pl test", "integration", result, output_dir)
def run_mvn_test(command, test_type, result, output_dir):
command += " -Pprecommit" # We need special test profile for precommit hook
test_file_name = "test_%s.txt" % (test_type)
test_output = "%s/%s" % (output_dir, test_file_name)
# Execute the test run
rc = execute("mvn %s 1>%s 2>&1" % (command, test_output))
# Test run statistics (number of executed/skipped tests)
executed_tests = 0
fd = open(test_output)
for line in fd:
if "Tests run:" in line:
matcher = re.search("^Tests run: ([0-9]+), Failures: ([0-9]+), Errors: ([0-9]+), Skipped: ([0-9]+), Time elapsed:", line)
if matcher:
executed_tests += int(matcher.group(1))
fd.close()
# Based on whether they are
if rc == 0:
result.success("All %s tests passed (executed %d tests)" % (test_type ,executed_tests) )
else:
failed_tests = []
for path in list(find_all_files(".")):
file_name = os.path.basename(path)
if file_name.startswith("TEST-") and file_name.endswith(".xml"):
fd = open(path)
for line in fd:
if "<failure" in line or "<error" in line:
matcher = re.search("TEST\-(.*).xml$", file_name)
if matcher:
failed_tests += [ "Test {{%s}}" % (matcher.groups()[0]) ]
fd.close()
result.error("Some of %s tests failed (%s, executed %d tests)" % (test_type, jenkins_file_link_for_jira("report", test_file_name), executed_tests), set(failed_tests))
def clean_folder(folder):
for the_file in os.listdir(folder):
file_path = os.path.join(folder, the_file)
try:
if os.path.isfile(file_path):
os.unlink(file_path)
except Exception, e:
print e
# Keep track of actions that we did with their results
class ResultItem(object):
FATAL = "FATAL"
ERROR = "ERROR"
WARNING = "WARNING"
INFO = "INFO"
SUCCESS = "SUCCESS"
def __init__(self, level, message, bullets=[]):
self.level = level
self.message = message
self.bullets = bullets
def cobertura_get_percentage(fd):
for line in fd:
if "All Packages" in line:
matcher = re.search(".*>([0-9]+)%.*>([0-9]+)%.*", line)
if matcher:
fd.close()
return (int(matcher.groups()[0]), int(matcher.groups()[1]))
fd.close()
return (-1, -1)
def cobertura_compare(result, output_dir, compare_url):
# Detailed report file
report = open("%s/%s" % (output_dir, "cobertura_report.txt"), "w+")
report.write("Cobertura compare report against %s\n\n" % compare_url)
# List of all modules for which the test coverage is worst then the compare base
lowers = []
# For each report that exists locally
for path in list(find_all_files(".", "^frame-summary\.html$")):
package = path.replace("/target/site/cobertura/frame-summary.html", "").replace("./", "")
remoteIo = None
try:
remoteIo = open_url("%s%s" % (compare_url, path))
except urllib2.HTTPError:
report.write("Package %s: Base is missing" % (package))
lowers.append("Package {{%s}}: Can't compare test coverage as base is missing." % (package))
continue
(localLine, localBranch) = cobertura_get_percentage(open(path))
(compareLine, compareBranch) = cobertura_get_percentage(remoteIo)
diffLine = localLine - compareLine
diffBranch = localBranch - compareBranch
report.write("Package %s: Line coverage %d (%d -> %d), Branch coverage %d (%d -> %d)\n" % (package, diffLine, compareLine, localLine, diffBranch, compareBranch, localBranch))
# Cobertura's percentage calculation doesn't seem to be deterministic - it can do +/- 1% even without change a single line. The goal here is to highlight
# significant troubles when the coverage is diminished substantially, so we're simply workarounding that by ignoring 1% of difference.
if diffLine < -1 or diffBranch < -1:
lowers.append("Package {{%s}} has lower test coverage: Line coverage decreased by %d%% (from %d%% to %d%%), Branch coverage decreased by %d%% (from %d%% to %d%%)" % (package, abs(diffLine), compareLine, localLine, abs(diffBranch), compareBranch, localBranch))
# Add to the JIRA summary report
if len(lowers) == 0:
result.success("Test coverage did not decreased (%s)" % jenkins_file_link_for_jira("report", "cobertura_report.txt"))
else:
result.warning("Test coverage has decreased (%s)" % jenkins_file_link_for_jira("report", "cobertura_report.txt"), lowers)
# Clean up
report.close()
def findbugs_get_bugs(fd):
root = ET.parse(fd).getroot()
bugs = {}
for file in root.findall("file"):
classname = file.attrib["classname"]
errors = len(list(file))
bugs[classname] = errors
fd.close()
return bugs
def findbugs_compare(result, output_dir, compare_url):
# Detailed report file
report = open("%s/%s" % (output_dir, "findbugs_report.txt"), "w+")
report.write("Findbugs compare report against %s\n\n" % compare_url)
# Lines to be added to summary on JIRA
summary = []
# For each report that exists locally
for path in list(find_all_files(".", "^findbugs\.xml$")):
package = path.replace("/target/findbugs.xml", "").replace("./", "")
remoteIo = None
try:
remoteIo = open_url("%s%s" % (compare_url, path))
except urllib2.HTTPError:
report.write("Package %s: Base is missing" % (package))
summary.append("Package {{%s}}: Can't compare classes as base is missing." % (package))
continue
local = findbugs_get_bugs(open(path))
remote = findbugs_get_bugs(remoteIo)
report.write("Processing package %s:\n" % (package))
# Identify the differences for each class
for classname, errors in local.iteritems():
report.write("* Class %s has %s errors.\n" % (classname, errors));
if classname in remote:
# This particular class is also known to our compare class
remoteErrors = remote[classname]
if(int(errors) > int(remoteErrors)):
summary.append("Package {{%s}}: Class {{%s}} increased number of findbugs warnings to %s (from %s)" % (package, classname, errors, remoteErrors))
else:
# This classes errors are completely new to us
summary.append("Package {{%s}}: Class {{%s}} introduced %s completely new findbugs warnings." % (package, classname, errors))
# Spacing between each package
report.write("\n")
# Add to JIRA summary
if len(summary) == 0:
result.success("No new findbugs warnings (%s)" % jenkins_file_link_for_jira("report", "findbugs_report.txt"))
else:
result.warning("New findbugs warnings (%s)" % jenkins_file_link_for_jira("report", "findbugs_report.txt"), summary)
# Finish detailed report
report.close()
class Result(object):
def __init__(self):
self._items = []
self.overall = "+1"
self.exit_handler = None
self.attachment = "Not Found"
self.start_time = datetime.datetime.now()
def error(self, msg, bullets=[]):
self.overall = "-1"
self._items.append(ResultItem(ResultItem.ERROR, msg, bullets))
def info(self, msg, bullets=[]):
self._items.append(ResultItem(ResultItem.INFO, msg, bullets))
def success(self, msg, bullets=[]):
self._items.append(ResultItem(ResultItem.SUCCESS, msg, bullets))
def warning(self, msg, bullets=[]):
self._items.append(ResultItem(ResultItem.WARNING, msg, bullets))
def fatal(self, msg, bullets=[]):
self.overall = "-1"
self._items.append(ResultItem(ResultItem.FATAL, msg, bullets))
self.exit_handler()
self.exit()
def exit(self):
sys.exit(0)
usage = "usage: %prog [options]"
parser = OptionParser(usage)
parser.add_option("--branch", dest="branch",
help="Local git branch to test against", metavar="trunk")
parser.add_option("--defect", dest="defect",
help="Defect name", metavar="FLUME-1787")
parser.add_option("--file", dest="filename",
help="Test patch file", metavar="FILE")
parser.add_option("--run-tests", dest="run_tests",
help="Run Tests", action="store_true")
parser.add_option("--run-integration-tests", dest="run_integration_tests",
help="Run Integration Tests", action="store_true")
parser.add_option("--username", dest="username",
help="JIRA Username", metavar="USERNAME", default="flumeqa")
parser.add_option("--output", dest="output_dir",
help="Directory to write output", metavar="DIRECTORY")
parser.add_option("--post-results", dest="post_results",
help="Post results to JIRA (only works in defect mode)", action="store_true")
parser.add_option("--password", dest="password",
help="JIRA Password", metavar="PASSWORD")
parser.add_option("--patch-command", dest="patch_cmd", default="git apply",
help="Patch command such as `git apply' or `patch'", metavar="COMMAND")
parser.add_option("-p", "--strip", dest="strip", default="1",
help="Remove <n> leading slashes from diff paths", metavar="N")
parser.add_option("--cobertura", dest="cobertura",
help="HTTP URL with past cobertura results that we should compare against")
parser.add_option("--findbugs", dest="findbugs",
help="HTTP URL with past findbugs results that we should compare against")
(options, args) = parser.parse_args()
if not (options.defect or options.filename):
print "FATAL: Either --defect or --file is required."
sys.exit(1)
if options.defect and options.filename:
print "FATAL: Both --defect and --file cannot be specified."
sys.exit(1)
if options.post_results and not options.password:
print "FATAL: --post-results requires --password"
sys.exit(1)
branch = options.branch
output_dir = options.output_dir
defect = options.defect
username = options.username
password = options.password
run_tests = options.run_tests
run_integration_tests = options.run_integration_tests
post_results = options.post_results
strip = options.strip
patch_cmd = options.patch_cmd
result = Result()
if os.path.isdir(options.output_dir):
clean_folder(options.output_dir)
# Default exit handler in case that we do not want to submit results to JIRA
def log_and_exit():
# Write down comment generated for jira (won't be posted)
write_file("%s/jira-comment.txt" % output_dir, jira_generate_comment(result, branch).replace("\\n", "\n"))
for item in result._items:
print "%s: %s" % (item.level, item.message)
for bullet in item.bullets:
print "* %s" % bullet
result.exit_handler = log_and_exit
if post_results:
def post_jira_comment_and_exit():
jira_post_comment(result, defect, branch, username, password)
result.exit()
result.exit_handler = post_jira_comment_and_exit
if not output_dir:
tmp_dir = tempfile.mkdtemp()
output_dir = tmp_dir
if output_dir.endswith("/"):
output_dir = output_dir[:-1]
if options.output_dir and not os.path.isdir(options.output_dir):
os.makedirs(options.output_dir)
# If defect parameter is specified let's download the latest attachment
if defect:
print "Defect: %s" % defect
jira_json = jira_get_defect(result, defect, username, password)
json = json.loads(jira_json)
# JIRA must be in Patch Available state
if '"Patch Available"' not in jira_json:
print "ERROR: Defect %s not in patch available state" % (defect)
sys.exit(1)
# If branch is not specified, let's try to guess it from JIRA details
if not branch:
versions = json_get_version(json)
branch = sqoop_guess_branch(versions)
if not branch:
print "ERROR: Can't guess branch name from %s" % (versions)
sys.exit(1)
attachment = jira_get_attachment(result, defect, username, password)
if not attachment:
print "ERROR: No attachments found for %s" % (defect)
sys.exit(1)
result.attachment = attachment
patch_contents = jira_request(result, result.attachment, username, password, None, {}).read()
patch_file = "%s/%s.patch" % (output_dir, defect)
with open(patch_file, 'a') as fh:
fh.write(patch_contents)
elif options.filename:
patch_file = options.filename
else:
raise Exception("Not reachable")
# Verify that we are on supported branch
if not sqoop_verify_branch(branch):
print "ERROR: Unsupported branch %s" % (branch)
sys.exit(1)
mvn_clean(result, output_dir)
git_checkout(result, branch)
git_apply(result, patch_cmd, patch_file, strip, output_dir)
static_test(result, patch_file, output_dir)
mvn_rat(result, output_dir)
mvn_install(result, output_dir)
# Unit tests are conditional
if run_tests:
mvn_test(result, output_dir)
# Cobertura report is conditional
if options.cobertura:
cobertura_compare(result, output_dir, options.cobertura)
# Findbugs report is also conditional
if options.findbugs:
findbugs_compare(result, output_dir, options.findbugs)
else:
result.info("Unit tests were skipped, please add --run-tests")
# Integration tests are conditional
if run_integration_tests:
mvn_integration(result, output_dir)
else:
result.info("Integration tests were skipped, please add --run-integration-tests")
result.exit_handler()