Commits

Miki Tebeka committed 7f7c0e7

Initial import

  • Participants

Comments (0)

Files changed (2)

File 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
+    }
+
+'''
+
+# TODO
+# * For some reason the patches are not anchored correctly
+
+# See Crucible REST API docs at
+# http://docs.atlassian.com/fisheye-crucible/latest/wadl/crucible.html#N20048
+# bit.ly/j0LjKT
+
+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' : patch.data,
+        '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_url
+            Base API url
+        user
+            User cridentials
+        path
+            Request path
+        data
+            Data to send
+    '''
+    url = '{0}{1}'.format(base_url, path) if path else base_url
+
+    headers = request_headers(user)
+    request = POSTRequest(url, headers=headers)
+    request.add_data(data)
+
+    reply = urlopen(request).read()
+    try:
+        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)
+
+    try:
+        return jira.getIssue(token, issue)['summary']
+    except Fault:
+        raise KeyError('issue {0} not found'.format(issue))
+    finally:
+        jira.logout(token)
+
+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
+    list.
+
+    `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
+    found.'''
+    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)
+
+VERBOSE = True
+def log(message):
+    '''Log message (when VERBOSE).'''
+    if VERBOSE:
+        print(message)
+
+def review_url(crucible_url, review_id):
+    '''Return the URL to the review.'''
+    suffix = '/rest-service/reviews-v1'
+    #"http://example.com/fisheye/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.'''
+    try:
+        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',
+                            version='0.1.0')
+    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)',
+                        default=None)
+    parser.add_argument('-u', '--user', help='Crucible user name', default=None)
+    parser.add_argument('-p', '--password', help='Crucible password',
+                        default=None)
+    parser.add_argument('-c', '--crucible-url', help='Crubicle API url',
+                        default=None)
+    parser.add_argument('-j', '--jira-url', help='JIRA API url (optional)',
+                        default=None)
+    parser.add_argument('-q', '--quiet', help='Be quiet', default=False,
+                        action='store_true')
+    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',
+                        default='Subversion')
+    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 .)',
+                        default='.')
+
+    args = parser.parse_args(argv[1:])
+
+    if not (args.name 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 args.name) 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)
+        args.name = '{0}: {1}'.format(args.issue, desc)
+
+    try:
+        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.patch.read(), 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, args.name, 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:
+        print(review_id)
+
+    if args.browser:
+        webbrowser.open(review_url(args.crucible_url, review_id))
+
+if __name__ == '__main__':
+    main()
+#!/usr/bin/env python
+
+from setuptools import setup
+
+setup(
+    name='crucible',
+    version='0.1.0',
+    description='Command line client to Crucible',
+    author='Miki Tebeka',
+    author_email='miki.tebeka@gmail.com',
+    url='https://bitbucket.org/tebeka/crucible/src',
+    license='MIT License',
+    platforms=['any'],
+    zip_safe=True,
+    scripts=['scripts/crucible'],
+)
+