crucible / scripts / crucible

#!/usr/bin/env python
'''Create a Crucible review from a patch file.

You can save some defaults in ~/.cruciblerc (JSON format), for example:

        "crucible_url" : "http://localhost:8060/rest-service/reviews-v1",
        "jira_url" : "http://localhost:8060/jira/rpc/xmlrpc",
        "user" : "daffy",
        "password" : "duck",
        "reviewers" : ["bugs", "tweety"]
        "repository" : "Subversion",
        "strip" : 0


# * For some reason the patches are not anchored correctly

# See Crucible REST API docs at

from collections import namedtuple
from functools import partial
from getpass import getpass, getuser
from os.path import expanduser, isfile
from subprocess import check_output, STDOUT, CalledProcessError
from urllib2 import urlopen, Request
from xmlrpclib import ServerProxy, Fault
import json
import webbrowser

User = namedtuple('User', ['login', 'password'])
Patch = namedtuple('Patch', ['data', 'strip', 'repository', 'path'])

class POSTRequest(Request):
    '''A request that always does POST.

    We need this since Crucible requires POST for the status change even when
    the request body is empty.
    def get_method(self):
        return 'POST'

def create_doc(user, name, patch):
    '''Create the initial document for creation of review.'''
    return json.dumps({
        'reviewData' : {
            'allowReviewersToJoin' : True,
            'author' : {'userName' : user.login},
            'creator' : {'userName' : user.login},
            'moderator' : {'userName' : user.login},
            'name' : name,
            'projectKey' : 'CR',
        'patch' :,
        'anchor' : {
            'anchorPath' : patch.path,
            'anchorRepository' : patch.repository,
            'stripCount' : patch.strip,

def request_headers(user):
    '''Request headers for Crucible: Authentication and content-type is JSON.'''
    auth = '{0}:{1}'.format(user.login, user.password).encode('base64')[:-1]
    mime_type = 'application/json'

    return {
        'Authorization' : 'Basic {0}'.format(auth),
        'Accept' : mime_type,
        'Content-Type' : mime_type,

def request(base_url, user, path, data=None):
    '''Do a request to Crubicle REST API.
    Note that this is always a POST request.

            Base API url
            User cridentials
            Request path
            Data to send
    url = '{0}{1}'.format(base_url, path) if path else base_url

    headers = request_headers(user)
    request = POSTRequest(url, headers=headers)

    reply = urlopen(request).read()
        return json.loads(reply)
    except ValueError:
        return reply

def svn_path(directory):
    '''Path in subversion of `directory`.'''
    root, path = None, None
    for line in check_output(['svn', 'info', directory]).splitlines():
        if line.startswith('URL:'):
            path = line.split(':', 1)[1].strip()
        elif line.startswith('Repository Root:'):
            root = line.split(':', 1)[1].strip()

    return path[len(root):]

def issue_description(issue, jira_url, user):
    '''Get issue description from JIRA.'''
    jira = ServerProxy(jira_url).jira1
    token = jira.login(user.login, user.password)

        return jira.getIssue(token, issue)['summary']
    except Fault:
        raise KeyError('issue {0} not found'.format(issue))

def create_new_review(user, name, patch, request):
    '''Create a new review, return the review ID.

    `request` is a partial of `request` with user and url.
    data = create_doc(user, name, patch)
    reply = request('', data)
    return reply['permaId']['id']

def add_reviewers(review_id, reviewers, request):
    '''Add reviewers to review. `reviewers` can be comma separated string or a

    `request` is a partail of `request` with user and url.
    if isinstance(reviewers, (tuple, list)):
        reviewers = ','.join(reviewers)

    request('/{0}/reviewers'.format(review_id), reviewers)

def start_review(review_id, request):
    '''Start a review.

    `request` is a partail of `request` with user and url.
    path = '/{0}/transition?action=action:approveReview'.format(review_id)
    request(path, None)

def read_config():
    '''Return user configuration (JSON), return {} if configuration file not
    rcfile = expanduser('~/.cruciblerc')
    if not isfile(rcfile):
        return {}

    with open(rcfile) as fo:
        return json.load(fo)

def populate_args(args, config):
    '''Popluate missing options in args from config.'''
    for key, value in config.iteritems():
        if not getattr(args, key, None):
            setattr(args, key, value)

def log(message):
    '''Log message (when VERBOSE).'''
    if VERBOSE:

def review_url(crucible_url, review_id):
    '''Return the URL to the review.'''
    suffix = '/rest-service/reviews-v1'
    base = crucible_url[:-len(suffix)]

    return '{0}/cru/{1}'.format(base, review_id)

def is_svn_project():
    '''Check that current directory is under svn.'''
        check_output(['svn', 'info'], stderr=STDOUT)
        return True
    except (CalledProcessError, OSError):
        return False

def main(argv=None):
    global VERBOSE

    import sys
    from argparse import ArgumentParser, FileType

    argv = argv or sys.argv

    parser = ArgumentParser(description='Create a review on Crucible',
    parser.add_argument('patch', help='Path file to upload', type=FileType('r'))
    parser.add_argument('-i', '--issue', default=None,
                    help='JIRA issue id ' +
                         'will make review name <issue>: <issue description>'),
    parser.add_argument('-n', '--name', help='Review name', default=None)
    parser.add_argument('-r', '--reviewers', help='Reviewers (comma separated)',
    parser.add_argument('-u', '--user', help='Crucible user name', default=None)
    parser.add_argument('-p', '--password', help='Crucible password',
    parser.add_argument('-c', '--crucible-url', help='Crubicle API url',
    parser.add_argument('-j', '--jira-url', help='JIRA API url (optional)',
    parser.add_argument('-q', '--quiet', help='Be quiet', default=False,
    parser.add_argument('-s', '--strip', default=0, type=int,
                        help='Strip level (0 for svn, 1 for hg)')
    parser.add_argument('--repository', help='FishEye repository',
    parser.add_argument('-b', '--browser', help='Open new CR page in browser',
                        default=False, action='store_true')
    parser.add_argument('--project-dir', help='Project directory (default to .)',

    args = parser.parse_args(argv[1:])

    if not ( or args.issue):
        parser.error('must specify either --name or --issue') # Will exit

    populate_args(args, read_config())

    if not args.user:
        args.user = getuser()

    if not args.password:
        args.password = getpass()
        if not args.password:
            raise SystemExit

    VERBOSE = not args.quiet

    user = User(args.user, args.password)

    # Get description from JIRA if no name
    if (not and args.issue:
        if not args.jira_url:
            raise SystemExit(
                'error: cannot get issue description without JIRA url')
        desc = issue_description(args.issue, args.jira_url, user) = '{0}: {1}'.format(args.issue, desc)

        svnpath = svn_path(args.project_dir)
    except CalledProcessError:
        raise SystemExit(
            'error: {0} is not under subversion'.format(args.project_dir))
    except OSError:
        raise SystemExit('error: "svn" command not found')

    patch = Patch(, args.strip, args.repository, svnpath)

    # Partial request object (with user and url) that is passed to API calls.
    _request = partial(request, args.crucible_url, user)

    log('Creating review')
    review_id = create_new_review(user,, patch, _request)
    log('Review {0} created'.format(review_id))

    if args.reviewers:
        log('Adding reviewers')
        add_reviewers(review_id, args.reviewers, _request)

    log('Starting review')
    start_review(review_id, _request)

    if args.quiet:

    if args.browser:, review_id))

if __name__ == '__main__':