blob: 5c17e587bd64bc8fa98ae90058d99da2bd4230a5 [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.
# This script will take your local git changes and upload them as a patch JIRA and review
# board. This script has been written to support Sqoop workflow but can work for any project
# that uses JIRA and review board.
#
# This tool depends on reviewboard python APIs, please download them
# from here: https://www.reviewboard.org/downloads/rbtools/
#
#
# Future improvement ideas
# * When submitting review request open an editor to let user fill in the details?
# * Add protection against uploading the same file (patch) twice?
# * Migrate all HTTP calls from urllib2 to requests?
import sys, os, re, urllib2, base64, subprocess, tempfile, shutil
import json
import datetime
import ConfigParser
import requests
from optparse import OptionParser
from rbtools.api.client import RBClient
# Resource file location
RC_PATH = os.path.expanduser("~/.upload-patch.rc")
# Default option values
DEFAULT_JIRA_URL = 'https://issues.apache.org/jira'
DEFAULT_JIRA_RB_LABEL = "Review board"
DEFAULT_JIRA_TRANSITION = "Patch Available"
DEFAULT_RB_URL = 'https://reviews.apache.org'
DEFAULT_RB_REPOSITORY = 'sqoop-sqoop2'
DEFAULT_RB_GROUP = 'sqoop'
DEFAULT_JIRA_USER = None
DEFAULT_JIRA_PASSWORD = None
DEFAULT_RB_USER = None
DEFAULT_RB_PASSWORD = None
# Loading resource file that can contain some parameters
if os.path.exists(RC_PATH):
rc = ConfigParser.RawConfigParser()
rc.read(RC_PATH)
# And override faults from the rc file
DEFAULT_JIRA_USER = rc.get("jira", "username")
DEFAULT_JIRA_PASSWORD = rc.get("jira", "password")
DEFAULT_RB_USER = rc.get("reviewboard", "username")
DEFAULT_RB_PASSWORD = rc.get("reviewboard", "password")
print "Loaded JIRA username from resource file: %s" % DEFAULT_JIRA_USER
print "Loaded Review board username from resource file: %s" % DEFAULT_RB_USER
else:
print "Resource file %s not found." % RC_PATH
# Options
parser = OptionParser("Usage: %prog [options]")
parser.add_option("--jira", dest="jira", help="JIRA number that this patch is for", metavar="SQOOP-1234")
parser.add_option("--jira-url", dest="jira_url", default=DEFAULT_JIRA_URL, help="URL to JIRA instance", metavar="http://jira.com/")
parser.add_option("--jira-user", dest="jira_user", default=DEFAULT_JIRA_USER, help="JIRA username", metavar="jarcec")
parser.add_option("--jira-transition",dest="jira_transition", default=DEFAULT_JIRA_TRANSITION,help="Name of the transition when uploading patch", metavar="Patch Available")
parser.add_option("--jira-password", dest="jira_password", default=DEFAULT_JIRA_PASSWORD, help="JIRA passowrd", metavar="secret")
parser.add_option("--jira-rb-label", dest="jira_rb_label", default=DEFAULT_JIRA_RB_LABEL, help="Label to be used in JIRA for the review board link", metavar="Review")
parser.add_option("--rb-url", dest="rb_url", default=DEFAULT_RB_URL, help="URL to Review board instance", metavar="http://rb.com/")
parser.add_option("--rb-group", dest="rb_group", default=DEFAULT_RB_GROUP, help="Review group for new review entry", metavar="sqoop")
parser.add_option("--rb-repository", dest="rb_repository", default=DEFAULT_RB_REPOSITORY, help="Review board's repository", metavar="sqoop2")
parser.add_option("--rb-user", dest="rb_user", default=DEFAULT_RB_USER, help="Review board username", metavar="jarcec")
parser.add_option("--rb-password", dest="rb_password", default=DEFAULT_RB_PASSWORD, help="Review board passowrd", metavar="secret")
parser.add_option("-v", "--verbose", dest="verbose", action="store_true", default=False, help="Print more debug information while execution")
# Execute given command on command line
def execute(cmd, options):
if options.verbose:
print "Executing command: %s" % (cmd)
return subprocess.call(cmd, shell=True)
# End program execution with given message and return code
def exit(message, ret=1):
print "FATAL: %s" % message
sys.exit(ret)
# Convert given number of bytes to human readable one
# Source: http://stackoverflow.com/questions/1094841/reusable-library-to-get-human-readable-version-of-file-size
def human_readable_size(num, suffix='B'):
for unit in ['','Ki','Mi','Gi','Ti','Pi','Ei','Zi']:
if abs(num) < 1024.0:
return "%3.1f%s%s" % (num, unit, suffix)
num /= 1024.0
return "%.1f%s%s" % (num, 'Yi', suffix)
# Load given file entirely into memory
def get_file_content(filepath):
f = open(filepath, mode="r")
diff = f.read()
f.close()
return diff
# Geneate request to JIRA instance
def jira_request(url, options, data, headers):
request = urllib2.Request(url, data, headers)
if options.verbose:
print "JIRA Request: URL = %s, Username = %s, data = %s, headers = %s" % (url, options.jira_user, data, str(headers))
if options.jira_user and options.jira_password:
base64string = base64.encodestring('%s:%s' % (options.jira_user, options.jira_password)).replace('\n', '')
request.add_header("Authorization", "Basic %s" % base64string)
return urllib2.urlopen(request)
# Get response from JIRA in form of JSON and parse the JSON for downstream consumption
def jira_json(url, options, data, headers):
body = jira_request(url, options, data, headers).read()
if options.verbose:
print "Response: %s" % body
return json.loads(body)
# General details of JIRA issue
def jira_get_issue(options):
url = "%s/rest/api/2/issue/%s" % (options.jira_url, options.jira)
return jira_json(url, options, None, {})
# Links associated with the JIRA
def jira_get_links(options):
url = "%s/rest/api/2/issue/%s/remotelink" % (options.jira_url, options.jira)
return jira_json(url, options, None, {})
# Create new link
def jira_post_links(link_url, title, options):
url = "%s/rest/api/2/issue/%s/remotelink" % (options.jira_url, options.jira)
data = '{"object" : {"url" : "%s", "title" : "%s"}}' % (link_url, title)
jira_request(url, options, data, {"Content-Type" : "application/json"})
# Possible transitions for JIRA
def jira_get_transitions(options):
url = "%s/rest/api/2/issue/%s/transitions?expand=transititions.fields" % (options.jira_url, options.jira)
return jira_json(url, options, None, {})
# Transition JIRA to give state
def jira_post_transitions(transitionId, options):
url = "%s/rest/api/2/issue/%s/transitions" % (options.jira_url, options.jira)
data = '{"transition" : {"id" : "%s"}}' % transitionId
jira_request(url, options, data, {"Content-Type" : "application/json"})
# Create new attachement
def jira_post_attachments(f, options):
url = "%s/rest/api/2/issue/%s/attachments" % (options.jira_url, options.jira)
files = {'file':open(f)}
headers = {"X-Atlassian-Token" : "no-check"}
requests.post(url, files=files, headers=headers, auth=(options.jira_user, options.jira_password)).text
# Parse and validate arguments
(options, args) = parser.parse_args()
if not options.jira:
exit("Missing argument --jira")
# Main execution
patch = "%s.patch" % options.jira
execute("git diff HEAD > %s" % patch, options)
if not os.path.exists(patch):
exit("Can't generate patch locally")
# Verify size of the patch
patchSize = os.path.getsize(patch)
if patchSize == 0:
exit("Generated empty patch, ending gracefully", 0)
else:
print "Created patch %s (%s)" % (patch, human_readable_size(patchSize))
# Retrive link to review board if it exists already
reviewBoardUrl = None
linksJson = jira_get_links(options)
for link in linksJson:
if link.get("object").get("title") == options.jira_rb_label:
reviewBoardUrl = link.get("object").get("url")
break
if options.verbose:
if reviewBoardUrl:
print "Found associated review board: %s" % reviewBoardUrl
else:
print "No associated review board entry found"
# Saving details of the JIRA for various use
print "Getting details for JIRA %s" % (options.jira)
jiraDetails = jira_get_issue(options)
# Verify that JIRA is properly marked with versions (otherwise precommit hook would fail)
versions = []
for version in jiraDetails.get("fields").get("versions"):
versions = versions + [version.get("name")]
for version in jiraDetails.get("fields").get("fixVersions"):
versions = versions + [version.get("name")]
if not versions:
exit("Both 'Affected Version(s)' and 'Fix Version(s)' JIRA fields are empty. Please fill one of them with desired version first.")
# Review board handling
rbClient = RBClient(options.rb_url, username=options.rb_user, password=options.rb_password)
rbRoot = rbClient.get_root()
# The RB REST API don't have call to return repository by name, only by ID, so one have to
# manually go through all the repositories and find the one that matches the corrent name.
rbRepoId = -1
for repo in rbRoot.get_repositories(max_results=500):
if repo.name == options.rb_repository:
rbRepoId = repo.id
break
# Verification that we have found required repository
if rbRepoId == -1:
exit("Did not found repository '%s' on review board" % options.rb_repository)
else:
if options.verbose:
print "Review board repository %s has id %s" % (options.rb_repository, rbRepoId)
# If review doesn't exists we need to create one, otherwise we will update existing one
if reviewBoardUrl:
# For review board REST APIs we need to get just the ID (the number)
linkSplit = reviewBoardUrl.split('/')
reviewId = linkSplit[len(linkSplit)-1]
print "Updating existing review request %s with new patch" % reviewId
# Review request itself
reviewRequest = rbRoot.get_review_request(review_request_id=reviewId)
# Update diff (the patch) and publish the changes
reviewRequest.get_diffs().upload_diff(get_file_content(patch))
draft = reviewRequest.get_draft()
draft.update(public=True)
else:
print "Creating new review request"
jiraSummary = jiraDetails.get('fields').get('summary')
jiraDescription = jiraDetails.get('fields').get('description')
# Create review request
reviewRequest = rbRoot.get_review_requests().create(repository=rbRepoId)
# Attach patch
reviewRequest.get_diffs().upload_diff(get_file_content(patch))
# And add details
draft = reviewRequest.get_draft()
draft = draft.update(
summary='%s: %s' % (options.jira, jiraSummary),
description=jiraDescription,
target_groups=options.rb_group,
target_people=options.rb_user,
bugs_closed=options.jira
)
draft.update(public=True)
linkSplit = draft.links.review_request.href.split('/')
reviewId = linkSplit[len(linkSplit)-2]
reviewBoardUrl = "%s/r/%s" % (options.rb_url, reviewId)
jira_post_links(reviewBoardUrl, options.jira_rb_label, options)
print "Created new review: %s" % reviewBoardUrl
# Verify state of the JIRA to see if it's in the right state
if jiraDetails.get("fields").get("status").get("name") != options.jira_transition:
# JIRA REST API needs transition ID and not the human readable name, so we have to translate it first
jiraTransitions = jira_get_transitions(options)
transitionId = -1
for transition in jiraTransitions.get("transitions"):
if transition.get("to").get("name") == options.jira_transition:
transitionId = transition.get("id")
if transitionId == -1:
exit("Did not find valid transition id for %s" % options.jira_transition)
else:
if options.verbose:
print "Transition id for transition %s is %s" % (options.jira_transition, transitionId)
# And finally switch to patch available state
jira_post_transitions(transitionId, options)
print "Switch JIRA %s to %s state" % (options.jira, options.jira_transition)
else:
if options.verbose:
print "JIRA %s is already in %s" % (options.jira, options.jira_transition)
# Upload generated patch to JIRA itself
jira_post_attachments(patch, options)
# And that's it!
print "And we're done!"