freephile

crucible.py

Feb 20th, 2014
503
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Python 47.86 KB | None | 0 0
  1. #!/usr/bin/env python
  2. # -*- coding: utf-8
  3.  
  4. from __future__ import division
  5. from __future__ import absolute_import
  6. from __future__ import print_function
  7.  
  8. import os.path
  9. import os
  10. import ConfigParser
  11. import sys
  12. import argparse
  13. import logging
  14. import json
  15. import urllib
  16. import urllib2
  17. import urlparse
  18. import base64
  19. import subprocess
  20. import re
  21. import getpass
  22. import traceback
  23. import locale
  24.  
  25. MIN_CRUCIBLE_VERSION = '3.0.0'
  26. SCRIPT_NAME = os.path.basename(__file__)
  27. SCRIPT_VERSION='732ec691cc8ddde4b140f2b2022347c2'
  28.  
  29.  
  30. ### subprocess wrappers
  31. def check_output(*popenargs, **kwargs):
  32.     """Run command with arguments and return its output as a byte string."""
  33.     if 'stdout' in kwargs or 'stderr' in kwargs:
  34.         raise ValueError('stdout argument not allowed, it will be overridden.')
  35.     logging.debug('Trying to execute %s', popenargs)
  36.     process = subprocess.Popen(stdout=subprocess.PIPE, stderr=subprocess.PIPE, *popenargs, **kwargs)
  37.     output, err = process.communicate()
  38.     retcode = process.poll()
  39.     if retcode:
  40.         cmd = kwargs.get("args")
  41.         if cmd is None:
  42.             cmd = popenargs[0]
  43.         logging.debug('Error executing, exit code %s\nstdout=%s\nstderr=%s', retcode, output, err)
  44.         raise CalledProcessError(retcode, cmd, output=output, error=err)
  45.     logging.debug('Finished executing, exit code 0\nstdout=%s\nstderr=%s', output, err)
  46.     return output
  47.  
  48.  
  49. class CalledProcessError(subprocess.CalledProcessError):
  50.     def __init__(self, returncode, cmd, output=None, error=None):
  51.         super(CalledProcessError, self).__init__(returncode, cmd, output)
  52.         self.error = error
  53.  
  54.  
  55. class Console:
  56.     NO_ANSI = sys.platform == 'win32' or not sys.stdout.isatty()
  57.     GREEN = '\033[92m'
  58.     RED = '\033[91m'
  59.     ESCAPE = '\033[0m'
  60.  
  61.     @staticmethod
  62.     def print(s, color=None):
  63.         if Console.NO_ANSI or not color:
  64.             print(s)
  65.         else:
  66.             print('%s%s%s' % (color, s, Console.ESCAPE))
  67.  
  68.     @staticmethod
  69.     def error(s):
  70.         Console.print(s, color=Console.RED)
  71.  
  72.     @staticmethod
  73.     def success(s):
  74.         Console.print(s, color=Console.GREEN)
  75.  
  76.  
  77. class HTTPRedirectHandler(urllib2.HTTPRedirectHandler):
  78.     """ Override HTTPRedirectHandler to make sure the request data is preserved on redirect """
  79.     def redirect_request(self, req, fp, code, msg, headers, newurl):
  80.         m = req.get_method()
  81.         if code in (301, 302, 303, 307) and m in ("GET", "HEAD") or code in (301, 302, 303) and m == "POST":
  82.             newurl = newurl.replace(' ', '%20')
  83.             newheaders = dict((k, v) for k, v in req.headers.items() if k.lower() not in 'content-length')
  84.             return urllib2.Request(newurl,
  85.                                    headers=newheaders,
  86.                                    data=req.data,
  87.                                    origin_req_host=req.get_origin_req_host(),
  88.                                    unverifiable=True)
  89.         else:
  90.             raise urllib2.HTTPError(req.get_full_url(), code, msg, headers, fp)
  91.  
  92.  
  93. class Configuration(object):
  94.     """Represents the configuration of the current execution of the script"""
  95.     def __init__(self):
  96.         self._url = None
  97.         self.username = None
  98.         self.password = None
  99.         self.authtoken = None
  100.         self._id = None
  101.         self.title = None
  102.         self.reviewers = []
  103.         self.moderator = None
  104.         self.executables = {
  105.             'hg': 'hg',
  106.             'svn': 'svn',
  107.             'git': 'git',
  108.             'p4': 'p4',
  109.             'cvs': 'cvs',
  110.         }
  111.         self.repository = None
  112.         self.last_project = None
  113.         self.initial_fill = True
  114.         self.no_anchor = False
  115.         self.encoding = None
  116.         self.diff_file = None
  117.         self.patch_source = None
  118.         self.new_patch_source = False
  119.  
  120.     @property
  121.     def url(self):
  122.         return self._url
  123.  
  124.     @url.setter
  125.     def url(self, value):
  126.         self._url = value.rstrip('/').strip() if value else None
  127.  
  128.     @property
  129.     def id(self):
  130.         return self._id
  131.  
  132.     @id.setter
  133.     def id(self, value):
  134.         self._id = value.strip() if value else None
  135.  
  136.     def fill_from_defaults(self):
  137.         self.url, self.username = self.url or 'http://atlassian-demo:8060', self.username or 'pmonteagudo'
  138.         return self
  139.  
  140.     def fill_from_config_file(self, config_file):
  141.         if not self.url:
  142.             self.url = config_file.get_default_url()
  143.  
  144.         if not self.password:
  145.             stored_token, stored_user = config_file.get_token(self.url)
  146.             if not self.username or self.username == stored_user:
  147.                 self.authtoken, self.username = stored_token, stored_user
  148.  
  149.         return self
  150.  
  151.     def fill_from_args(self, args):
  152.         self.url = args.server or self.url
  153.         self.username = args.user or self.username
  154.         self.password = args.password or self.password
  155.         self.title = args.title or self.title
  156.  
  157.         args_dict = vars(args)
  158.         self.id = args_dict[str('project/review')] or self.id
  159.         if '@reviewer' in args_dict:
  160.             for reviewer in args_dict[str('@reviewer')]:
  161.                 self.reviewers.append(reviewer.lstrip('@').strip())
  162.                 logging.debug('Parsed reviewers: %s', self.reviewers)
  163.         self.moderator = args.moderator.lstrip('@').strip() if args.moderator else self.moderator
  164.         self.repository = args.repository or self.repository
  165.         self.no_anchor = args.noanchor or self.no_anchor
  166.         self.encoding = args.encoding or self.encoding
  167.         self.diff_file = args.file or self.diff_file
  168.         self.new_patch_source = args.newpatch or self.new_patch_source
  169.  
  170.         return self
  171.  
  172.     def validate(self, check_title=False, check_id=False):
  173.         """Checks if all the required server options are set"""
  174.         if self.url and (self.authtoken or (self.username and self.password)) and (not check_id or self.id) and (not check_title or self.title):
  175.             return True
  176.  
  177.         if not self.url:
  178.             Console.error('ERROR: Please specify a Crucible server')
  179.         if not self.authtoken and not self.username:
  180.             Console.error('ERROR: Please specify a Crucible username')
  181.         if not self.authtoken and not self.password:
  182.             Console.error('ERROR: Please specify a Crucible password')
  183.         if check_id and not self.id:
  184.             Console.error('ERROR: Please specify a project or review id')
  185.         if check_title and not self.title:
  186.             Console.error('ERROR: Please specify a review title')
  187.         print(CommandLine().help_blurb())
  188.         sys.exit(1)
  189.  
  190.     def fill_interactively(self, get_title=False, get_id=False, get_reviewers=False):
  191.         """Fills the required parameters by prompting the user if they're not specified"""
  192.         if not sys.stdin.isatty():
  193.             logging.debug('Not prompting for parameters interactively because stdin is not a tty')
  194.         else:
  195.             if not self.url:
  196.                 self.url = raw_input('Please enter the Crucible server URL: ')
  197.             elif self.initial_fill:
  198.                 print('Crucible server: %s' % self.url)
  199.  
  200.             if not self.username:
  201.                 self.username = raw_input('Please enter your Crucible username: ')
  202.             elif self.initial_fill:
  203.                 print("Crucible username: %s" % self.username)
  204.  
  205.             if not self.password and not self.authtoken:
  206.                 self.password = getpass.getpass(str('Please enter your Crucible password: '))
  207.  
  208.             if get_id:
  209.                 while not self.id:
  210.                     last_project_prompt = ' [%s]' % self.last_project if self.last_project else ''
  211.                     self.id = raw_input('Please specify a project to create the review in or an existing review id to add to%s: ' % last_project_prompt).strip()
  212.                     self.id = self.id or self.last_project
  213.  
  214.             if get_title:
  215.                 while not self.title:
  216.                     self.title = raw_input('Please specify the review title: ')
  217.  
  218.             if get_reviewers and not self.reviewers:
  219.                 while True:
  220.                     reviewer = raw_input('Please specify a reviewer to be added to the review, or press Enter to continue: ').strip()
  221.                     if not reviewer:
  222.                         break
  223.                     if not reviewer in self.reviewers:
  224.                         self.reviewers.append(reviewer)
  225.  
  226.         self.validate(check_title=get_title, check_id=get_id)
  227.         self.initial_fill = False
  228.  
  229.         return self
  230.  
  231.     def choose_anchor(self, repositories):
  232.         """Allows choosing an anchor repository if none detected"""
  233.         if not sys.stdin.isatty():
  234.             logging.debug('Not prompting for anchor interactively because stdin is not a tty')
  235.             return
  236.  
  237.         repository_names = [str(repository.get('name'))
  238.                             for repository in filter(lambda repository: repository.get('enabled'), repositories)]
  239.         while not self.repository and not self.no_anchor:
  240.             repository = raw_input("Please choose a repository to anchor to, or press Enter to skip anchoring: ")
  241.             if not repository:
  242.                 self.no_anchor = True
  243.             elif repository in repository_names:
  244.                 self.repository = repository
  245.             else:
  246.                 print('The repository doesn\'t exist or is disabled')
  247.  
  248.     def choose_source(self, matching_patch_groups):
  249.         """Allows choosing a source from a list of matching ones"""
  250.         if len(matching_patch_groups) == 1:
  251.             print('Adding patch to existing one: %s. Use --newpatch to add as a new patch instead.' % matching_patch_groups[0]['displayName'])
  252.             self.patch_source = matching_patch_groups[0]['sourceName']
  253.         elif len(matching_patch_groups) > 1:
  254.             if not sys.stdin.isatty():
  255.                 logging.debug('Not prompting for source interactively because stdin is not a tty')
  256.                 return
  257.  
  258.             print('Found %s patches to add to:' % len(matching_patch_groups))
  259.             i = 1
  260.             print('0. Create a new patch')
  261.             for patch_group in matching_patch_groups:
  262.                 print('%s. Add to %s' % (i, patch_group['displayName']))
  263.                 i += 1
  264.  
  265.             while not self.patch_source and not self.new_patch_source:
  266.                 try:
  267.                     choice = int(raw_input('Pick patch to add to [0-%s]: ' % len(matching_patch_groups)))
  268.                     if 0 < choice <= len(matching_patch_groups):
  269.                         self.new_patch_source = False
  270.                         self.patch_source = matching_patch_groups[choice - 1]['sourceName']
  271.                     elif choice == 0:
  272.                         self.new_patch_source = True
  273.                         self.patch_source = None
  274.                 except ValueError:
  275.                     pass
  276.  
  277.  
  278.  
  279. class ConfigFile:
  280.     """Handles reading and storing settings from the config file"""
  281.     userConfigPath = os.path.expanduser('~/.atlassian/crucible.conf')
  282.     DEFAULT_SECTION = 'DEFAULT'
  283.     EXECUTABLES_SECTION = 'executables'
  284.     URL = 'url'
  285.     TOKEN = 'authtoken'
  286.     USER = 'user'
  287.  
  288.     def __init__(self):
  289.         self.config_parser = ConfigParser.RawConfigParser()
  290.         self.config_parser.read([self.userConfigPath])
  291.  
  292.     def _get(self, section, key):
  293.         if not self.config_parser.has_option(section, key):
  294.             return None
  295.         return self.config_parser.get(section, key)
  296.  
  297.     def get_default_url(self):
  298.         return self._get(self.DEFAULT_SECTION, self.URL)
  299.  
  300.     def store_configuration(self, configuration):
  301.         if self._get(self.DEFAULT_SECTION, self.URL) != configuration.url:
  302.             self.config_parser.set(self.DEFAULT_SECTION, self.URL, configuration.url)
  303.             print('Saved the default server URL %s to %s' % (configuration.url, self.userConfigPath))
  304.  
  305.         self.store_token(configuration.url, configuration.username, configuration.authtoken)
  306.         self.save()
  307.  
  308.     def get_token(self, url):
  309.         if not self.config_parser.has_section(url):
  310.             return None, None
  311.         return self._get(url, self.TOKEN), self._get(url, self.USER)
  312.  
  313.     def store_token(self, url, user, token):
  314.         if not self.get_token(url) == (token, user):
  315.             if not self.config_parser.has_section(url):
  316.                 self.config_parser.add_section(url)
  317.             self.config_parser.set(url, self.TOKEN, token)
  318.             self.config_parser.set(url, self.USER, user)
  319.             print ('Saved an authentication token for %s to %s' % (url, self.userConfigPath))
  320.  
  321.     def save(self):
  322.         try:
  323.             if not os.path.exists(os.path.dirname(self.userConfigPath)):
  324.                 os.makedirs(os.path.dirname(self.userConfigPath))
  325.  
  326.             with os.fdopen(os.open(self.userConfigPath, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600), 'wb') as configfile:
  327.                 self.config_parser.write(configfile)
  328.         except IOError as e:
  329.             print('Error saving the configuration file %s - %s' % (self.userConfigPath, e))
  330.             logging.debug(traceback.format_exc())
  331.  
  332.  
  333. class CommandLine:
  334.     """Handles parsing the commandline parameters"""
  335.     def __init__(self):
  336.         self.parser = argparse.ArgumentParser(
  337.             formatter_class=argparse.RawTextHelpFormatter,
  338.             description='Creates reviews in Atlassian Crucible from the command line',
  339.             add_help=False,
  340.             epilog='''
  341. EXAMPLE USAGE:
  342. %(executable)s
  343.                        run interactively, try to get a patch from SCM, prompt for review details and create a new review\n
  344. cat diff | %(executable)s CR -m "Review title"
  345.                        take the patch from the output of first command, and create a review in project CR with title "Review title"\n
  346. %(executable)s CR @ted @matt --moderator @john
  347.                        try to get a patch from SCM, create a review in project CR add ted and matt as reviewers, and john as moderator\n
  348. %(executable)s CR-120
  349.                        update the review CR-120, adding the current patch from SCM\n
  350. %(executable)s -r repository1
  351.                        create a review and anchor it to repository1, instead of trying to detect the repository automatically\n
  352.            ''' % {'executable': SCRIPT_NAME})
  353.  
  354.         class HelpAction(argparse._HelpAction):
  355.             def __call__(self, parser, namespace, values, option_string=None):
  356.                 print(parser.format_help()
  357.                 .replace('usage:', 'USAGE:')
  358.                 .replace('positional arguments:', 'POSITIONAL ARGUMENTS:')
  359.                 .replace('optional arguments:', 'OPTIONAL ARGUMENTS:'))
  360.                 parser.exit()
  361.  
  362.         self.parser.add_argument('project/review', type=str, nargs='?', default=None, help='the name of the project to create the review in or the id of a review to update\n\n')
  363.         self.parser.add_argument('@reviewer', type=str, nargs='*', default=[], help='the usernames of the reviewers to be added')
  364.         self.parser.add_argument('-h', '--help', action=HelpAction, help='show this help message and exit\n\n')
  365.         self.parser.add_argument('-m', '--title', type=str, help="the title for the review\n\n")
  366.         self.parser.add_argument('-M', '--moderator', type=str, help="the moderator for the review\n\n")
  367.         self.parser.add_argument('-r', '--repository', type=str, help='the repository to anchor to\n\n')
  368.         self.parser.add_argument('-f', '--file', type=str, help='get the diff from the specified file\n\n')
  369.         self.parser.add_argument('-s', '--server', type=str, help="the url of the Crucible server to connect to\n\n")
  370.         self.parser.add_argument('-u', '--user', type=str, help="the Crucible username to create the review as\n\n")
  371.         self.parser.add_argument('-p', '--password', type=str, help="the Crucible user password\n\n")
  372.         self.parser.add_argument('-n', '--noanchor', action='store_const', const=True, help='don\'t try to detect the repository to anchor the patch\n\n')
  373.         self.parser.add_argument('-N', '--newpatch', action='store_const', const=True, help='add as a new patch instead of trying to an existing one\n\n')
  374.         self.parser.add_argument('-e', '--encoding', type=str, help='the name of the encoding to use, see http://docs.python.org/2/library/codecs.html#standard-encodings\n\n')
  375.         self.parser.add_argument('-d', '--debug', action='store_const', const=True, help="print debugging information\n\n")
  376.         self.parser.add_argument('-v', '--version', action='version', version=('%s %s' % (SCRIPT_NAME, SCRIPT_VERSION)))
  377.  
  378.     def parse_args(self):
  379.         args = self.parser.parse_args()
  380.         if args.debug:
  381.             logging.basicConfig(stream=sys.stderr, level=logging.DEBUG)
  382.         if args.noanchor and args.repository:
  383.             Console.error('Please choose either --noanchor or --repository')
  384.             print(self.help_blurb())
  385.             sys.exit(1)
  386.  
  387.         return args
  388.  
  389.     def help_blurb(self):
  390.         return 'Try \'%s --help\' for more information.' % os.path.basename(sys.argv[0])
  391.  
  392. class CrucibleRest:
  393.     """Encapsulates Crucible REST endopoints"""
  394.     timeout = 30000
  395.     code_anchor_failed = 'PatchAnchorFailed'
  396.     code_content_too_large = 'ChangeSetContentTooLarge'
  397.     code_review_content_too_large = 'ReviewContentTooLarge'
  398.  
  399.     http_handlers_none_on_errors = {
  400.         400: lambda http_error, error_body: None,
  401.         404: lambda http_error, error_body: None,
  402.     }
  403.  
  404.     def __init__(self, configuration):
  405.         self.configuration = configuration
  406.         self.headers = {
  407.             'Content-Type': 'application/json',
  408.             'Accept': 'application/json',
  409.         }
  410.         self.repositories = None
  411.  
  412.     def __build_headers(self, custom_headers, use_token):
  413.         headers = {}
  414.         for key, value in self.headers.items():
  415.             headers[key] = value
  416.         if not use_token and self.configuration.username and self.configuration.password:
  417.             headers['Authorization'] = 'Basic ' + base64.b64encode(
  418.                 str('%s:%s') % (self.configuration.username, self.configuration.password)).strip(),
  419.         for key, value in custom_headers.items():
  420.             headers[key] = value
  421.         return headers
  422.  
  423.     def __build_payload(self, data):
  424.         if not data:
  425.             return None
  426.  
  427.         encodings = []
  428.         if self.configuration.encoding:
  429.             encodings.append(self.configuration.encoding)
  430.         else:
  431.             encodings.append('UTF-8')
  432.             default_encoding = locale.getdefaultlocale()[1]
  433.             if default_encoding and default_encoding not in encodings:
  434.                 encodings.append(default_encoding)
  435.             if sys.stdin.encoding not in encodings:
  436.                 encodings.append(sys.stdin.encoding)
  437.  
  438.         payload = None
  439.         for encoding in encodings:
  440.             try:
  441.                 if isinstance(data, unicode):
  442.                     payload = data
  443.                 if isinstance(data, str):
  444.                     logging.debug('Trying to encode str as %s', encoding)
  445.                     payload = unicode(data, encoding=encoding).encode('utf-8')
  446.                 else:
  447.                     logging.debug('Trying to encode json as %s', encoding)
  448.                     payload = json.dumps(data, encoding=encoding)
  449.                 break
  450.             except ValueError:
  451.                 logging.debug('Encoding failed: %s', traceback.format_exc())
  452.         if not payload:
  453.             Console.error(
  454.                 'Error encoding the request (tried %s), please specify an --encoding parameter' % ', '.join(
  455.                     encodings))
  456.             sys.exit(1)
  457.  
  458.         return payload
  459.  
  460.     def __build_url(self, url, use_token):
  461.         joined_url = self.configuration.url + url
  462.         if not use_token:
  463.             return joined_url
  464.         else:
  465.             scheme, netloc, path, query, fragment = urlparse.urlsplit(joined_url)
  466.             query_dict = urlparse.parse_qs(query)
  467.             query_dict['FEAUTH'] = [self.configuration.authtoken]
  468.             return urlparse.urlunsplit((scheme, netloc, path, urllib.urlencode(query_dict, doseq=True), fragment))
  469.  
  470.     def _log_request(self, headers, payload, resourceUrl):
  471.         resourceUrl = re.sub(r'FEAUTH=(.*)%3A([0-9]+)%3A([0-9a-f]+)', r'FEAUTH=\1%3A\2%3A++SANITIZED++', resourceUrl)
  472.         if payload:
  473.             payload = re.sub(r'password=(.*)([\s&]?)', r'password=++SANITIZED++\2', payload)
  474.         if headers and headers.get('Authorization'):
  475.             headers['Authorization'] = '++SANITIZED++'
  476.         logging.debug('RestRequest: %s - %s -  %s', resourceUrl, headers, payload)
  477.  
  478.     def _log_response(self, response):
  479.         if response:
  480.             response = re.sub(r'"token":"(.*):([0-9]+):([0-9a-f]+)"', r'"token":"\1:\2:++SANITIZED++"', response)
  481.         logging.debug('RestResponse: %s', response)
  482.  
  483.     def _request(self, url, data=None, http_handlers = {}, use_token=True, custom_headers={}):
  484.         """Executes a REST request the given rest resource, It's a POST if data is set, a GET otherwise"""
  485.         resourceUrl = self.__build_url(url, use_token)
  486.         headers = self.__build_headers(custom_headers, use_token)
  487.         request = urllib2.Request(url=resourceUrl, headers=headers)
  488.         payload = self.__build_payload(data)
  489.  
  490.         self._log_request(headers, payload, resourceUrl)
  491.  
  492.         try:
  493.             response = urllib2.build_opener(HTTPRedirectHandler()).open(request, data=payload, timeout=self.timeout).read()
  494.         except urllib2.HTTPError as error:
  495.             logging.debug('RestError: %s', error)
  496.             error_body = error.read()
  497.             try:
  498.                 error_body = json.loads(error_body)
  499.             except ValueError:
  500.                 pass
  501.             logging.debug('RestErrorBody: %s', error_body)
  502.  
  503.             if error.code in http_handlers:
  504.                 return http_handlers[error.code](error, error_body)
  505.  
  506.             if error.code == 404:
  507.                 error_msg = error_body['message'] if isinstance(error_body, dict) and 'message' in error_body \
  508.                             else 'Please check that %s is a Crucible server and the version is at least %s' % (self.configuration.url, MIN_CRUCIBLE_VERSION)
  509.                 Console.error('Error : %s' % error_msg)
  510.             elif error.code == 401:
  511.                 Console.error('Authorization error: please check that your username and password are valid, and that you have the correct permissions.')
  512.             elif error.code == 403:
  513.                 error_msg = error_body['message'] if isinstance(error_body, dict) and 'message' in error_body else 'You do not have permission to perform this operation'
  514.                 permissions_msg = 'Please contact your administrator if you need access'
  515.                 Console.error('Error: %s\n%s' % (error_msg, permissions_msg))
  516.             elif error.code == 500:
  517.                 Console.error('Server Error: %s' % error_body['message'] if isinstance(error_body, dict) and 'message' in error_body else error_body)
  518.             else:
  519.                 Console.error('Received an unexpected response %s. Please check that %s is a Crucible server' % (error, self.configuration.url))
  520.             sys.exit(1)
  521.         except Exception as error:
  522.             logging.debug(traceback.format_exc())
  523.             Console.error('Eror executing request %s - %s' % (resourceUrl, error))
  524.             sys.exit(1)
  525.  
  526.         self._log_response(response)
  527.         if 200 in http_handlers:
  528.             return http_handlers[200](response)
  529.  
  530.         try:
  531.             return json.loads(response)
  532.         except ValueError:
  533.             return response
  534.  
  535.     def check_connection(self):
  536.         """ Makes sure we can connect and authenticate with Crucible"""
  537.         # if there's a token, check that it's still valid
  538.         server_info = None
  539.         if self.configuration.authtoken:
  540.             server_info = self._request(url="/rest-service-fecru/server-v1", http_handlers={
  541.                 401: lambda error, error_body: None
  542.             })
  543.             if not server_info:
  544.                 print("Your login token has expired, please re-authenticate")
  545.                 self.configuration.authtoken = None
  546.                 self.configuration.fill_interactively()
  547.  
  548.         # get a token if none defined at this point
  549.         if not self.configuration.authtoken:
  550.             logging.debug('No authtoken, trying to get one')
  551.             login_success, login_response = self._request(
  552.                 url='/rest-service/auth-v1/login',
  553.                 data=urllib.urlencode({'userName':self.configuration.username, 'password':self.configuration.password}),
  554.                 http_handlers={
  555.                     403: lambda error, error_body: (False, error_body),
  556.                     200: lambda response: (True, json.loads(response))
  557.                 },
  558.                 use_token=False,
  559.                 custom_headers={'Content-Type':'application/x-www-form-urlencoded'}
  560.             )
  561.  
  562.             if not login_success:
  563.                 message = login_response['error'] if isinstance(login_response, dict) and 'error' in login_response else 'Please check that the username and password provided are correct.'
  564.                 Console.error('Error authenticating with Crucible. ' + message)
  565.                 sys.exit(1)
  566.             self.configuration.authtoken = login_response['token']
  567.  
  568.         server_info = server_info or self._request(url="/rest-service-fecru/server-v1")
  569.         if not server_info['isCrucible']:
  570.             Console.error('Connected successfully to %s but no Crucible license is present.' % self.configuration.url)
  571.             sys.exit(1)
  572.         logging.debug('Connected successfully to %s - Crucible version %s', self.configuration.url, server_info['version']['releaseNumber'])
  573.  
  574.     def add_reviewers(self, review_id, reviewers):
  575.         """Adds the configuration.reviewers to the review with the given id. Returns True if any reviewers were added"""
  576.         if not reviewers: return False
  577.  
  578.         logging.debug('Adding reviewers: %s', ', '.join(reviewers))
  579.         reviewers_added = False
  580.         for reviewer in reviewers:
  581.             if reviewer:
  582.                 reviewer_added, reviewer_msg = self._request("/rest-service/reviews-v1/%s/reviewers" % str(review_id), data=reviewer,
  583.                     http_handlers={
  584.                         404: lambda http_error, error_body: (False, 'No user \'%s\' found - not adding as a reviewer' % reviewer),
  585.                         400: lambda http_error, error_body: (False, error_body['message'] if 'message' in error_body else 'Error adding reviewer %s' % reviewer ),
  586.                         200: lambda response: (True, '')
  587.                     })
  588.                 if not reviewer_added:
  589.                     print(reviewer_msg)
  590.                 else:
  591.                     reviewers_added = True
  592.         return reviewers_added
  593.  
  594.     def handle_anchor_error(self, http_error, error_body):
  595.         if error_body.get('code') == CrucibleRest.code_anchor_failed:
  596.             Console.error('Error: Failed to anchor the patch to repository %s, please check that the repository is the correct one' % self.configuration.repository)
  597.             sys.exit(1)
  598.         elif error_body.get('code') == CrucibleRest.code_content_too_large or error_body.get('code') == CrucibleRest.code_review_content_too_large:
  599.             Console.error('Error: The patch you\'re trying to upload is too large. ' + error_body.get('message'))
  600.             sys.exit(1)
  601.         else:
  602.             Console.error('Received a 409 Conflict response: %s' % error_body['message'])
  603.  
  604.     def create_review(self, patch, project):
  605.         """Creates a new review from the patch, in the given project"""
  606.         logging.debug('Creating new review in project %s', project['key'])
  607.         review_data = {"reviewData": {
  608.             "projectKey": project['key'],
  609.             "name": self.configuration.title,
  610.             "description": project['defaultObjectives'] if 'defaultObjectives' in project else None,
  611.             },
  612.         }
  613.  
  614.         review_data = self.add_patch_data(patch, request_dict=review_data)
  615.  
  616.         if self.configuration.moderator:
  617.             if not project['moderatorEnabled']:
  618.                 print('Project %s doesn\'t have a moderator role enabled, not setting a moderator' % project['key'])
  619.             else:
  620.                 review_data['reviewData']['moderator'] = {'userName':self.configuration.moderator}
  621.  
  622.  
  623.         create_response = self._request("/rest-service/reviews-v1", review_data, http_handlers={
  624.             409: self.handle_anchor_error
  625.         })
  626.         review_id = create_response['permaId']['id']
  627.         review_state = create_response['state']
  628.  
  629.         reviewers_added = self.add_reviewers(review_id, self.configuration.reviewers)
  630.  
  631.         if reviewers_added or project['defaultReviewers']:
  632.             logging.debug('Starting review')
  633.             approve_response = self._request('/rest-service/reviews-v1/%s/transition?action=action:approveReview' % review_id, data=' ', http_handlers={
  634.                 401: lambda http_error, error_body: print('You don\'t have permission to approve the review')
  635.             })
  636.             if approve_response:
  637.                 review_state = approve_response['state']
  638.         else:
  639.             print('No reviewers added, review will be left in Draft state')
  640.  
  641.         Console.success('Created review %(id)s (state: %(state)s) - %(url)s/cru/%(id)s'
  642.               % ({'id': review_id, 'state': review_state,'url':self.configuration.url}))
  643.  
  644.     def add_patch_data(self, patch, request_dict={}):
  645.         request_dict['patch'] = patch
  646.         if self.configuration.repository:
  647.             request_dict['anchor'] = {'anchorRepository' : self.configuration.repository}
  648.         return request_dict
  649.  
  650.     def get_iterable_patchgroups(self, review_id, repository):
  651.         patch_groups = self._request('/rest-service/reviews-v1/%s/patch' % review_id)['patchGroup']
  652.         matching_repo = lambda patch_group: 'anchor' in patch_group['patches'][0] and \
  653.                                             patch_group['patches'][0]['anchor'].get('anchorRepository') == repository
  654.         return filter(matching_repo, patch_groups)
  655.  
  656.     def add_to_review(self, patch, review):
  657.         """Adds the patch and reviewers to the given review"""
  658.         review_id = review['permaId']['id']
  659.         data = self.add_patch_data(patch)
  660.  
  661.         if 'anchor' in data and not self.configuration.new_patch_source:
  662.             matching_patch_groups = self.get_iterable_patchgroups(review_id, data['anchor']['anchorRepository'])
  663.             self.configuration.choose_source(matching_patch_groups)
  664.             if self.configuration.patch_source:
  665.                 data['source'] = self.configuration.patch_source
  666.  
  667.         logging.debug('Adding patch to review %s' % review_id)
  668.  
  669.         patch_response = self._request('/rest-service/reviews-v1/%s/patch' % review_id, data=data, http_handlers={
  670.             409: self.handle_anchor_error
  671.         })
  672.         self.add_reviewers(review_id, self.configuration.reviewers)
  673.  
  674.         Console.success('Updated review %(id)s (state: %(state)s) - %(url)s/cru/%(id)s'
  675.               % ({'id': review_id, 'state': patch_response['state'],'url':self.configuration.url}))
  676.  
  677.     def get_review(self, id):
  678.         """A ReviewData json if the id represents an existing review, None otherwise"""
  679.         try:
  680.             return self._request('/rest-service/reviews-v1/%s' % id, http_handlers=CrucibleRest.http_handlers_none_on_errors)
  681.         except StandardError:
  682.             return None
  683.  
  684.     def get_project(self, id):
  685.         """A ProjectDat json if the id represents an existing project, None otherwise"""
  686.         try:
  687.             return self._request('/rest-service/projects-v1/%s?excludeAllowedReviewers=true' % id, http_handlers=CrucibleRest.http_handlers_none_on_errors)
  688.         except StandardError:
  689.             return None
  690.  
  691.     def get_last_project(self):
  692.         """Retrieves the most recent project, or None if it fails"""
  693.         try:
  694.             recent_projects = self._request('/rest-service-fecru/recently-visited-v1/projects')['project']
  695.             if(len(recent_projects)) == 0: return None
  696.             return recent_projects[0]['entityId']
  697.         except StandardError:
  698.             logging.debug('Error getting last project: %s', traceback.format_exc())
  699.             return None
  700.  
  701.     def find_repository(self, source):
  702.         """Tries to find the anchor repository for the source given"""
  703.         try:
  704.             for repository in self.get_repositories():
  705.                 logging.debug('Matching remote repository %s', repository['name'])
  706.                 if repository['enabled'] and source.matches_repository(repository):
  707.                     return repository['name']
  708.         except StandardError:
  709.             logging.debug('Error suggesting anchor repository: %s', traceback.format_exc())
  710.  
  711.         return None
  712.  
  713.     def get_repositories(self):
  714.         if not self.repositories:
  715.             self.repositories = self._request('/rest-service/repositories-v1')['repoData']
  716.         return self.repositories
  717.  
  718.     def is_script_update_available(self):
  719.         return self._request('/rest/review-cli/1.0/version/updateCheck?version=%s' % SCRIPT_VERSION)['isUpdateAvailable']
  720.  
  721. class PatchSource(object):
  722.     """Base class for different ways to get a patch"""
  723.     def __init__(self, configuration):
  724.         self.configuration = configuration
  725.         self.paths = []
  726.  
  727.     def is_active(self):
  728.         """Should return True if this is the source can be used"""
  729.  
  730.     def get_patch(self):
  731.         """Should return the patch content"""
  732.  
  733.     def get_review(self):
  734.         """A user visible name for the source"""
  735.  
  736.     def executable(self):
  737.         """The binary to execute commands on the repository"""
  738.  
  739.     def matches_repository(self, repository_data):
  740.         """Whether the source matches the given RepositoryData json"""
  741.  
  742.     def load_patch(self):
  743.         """Queries different patch sources for a patch"""
  744.         for source_class in PatchSource.__subclasses__():
  745.             logging.debug('Checking %s.is_active', source_class)
  746.             source = source_class(self.configuration)
  747.             if source.is_active():
  748.                 source._validate_executable()
  749.                 logging.debug('Getting patch from %s', source)
  750.                 patch = source.get_patch()
  751.                 if patch:
  752.                     logging.debug('Got %s bytes', len(patch))
  753.  
  754.                     if not self.configuration.no_anchor and not self.configuration.repository:
  755.                         source.load_paths()
  756.                         logging.debug('Loaded paths: %s', source.paths)
  757.  
  758.                 return patch, source
  759.         return None, None
  760.  
  761.     def _validate_executable(self):
  762.         try:
  763.             self.validate_executable()
  764.         except (OSError, CalledProcessError) as e:
  765.             Console.error('A %s repository was detected, but there was an error executing \'%s\': %s' % (self, self.configuration.executables[self.executable()], e))
  766.             sys.exit(1)
  767.  
  768.     def validate_executable(self):
  769.         pass
  770.  
  771.     def load_paths(self):
  772.         """Loads the potential remote paths for the given repo type to be used when trying to detect a repository to anchor to"""
  773.         pass
  774.  
  775.     def matches_url(self, url):
  776.         requested_path = urlparse.urlsplit(url)
  777.         logging.debug('Matching remote url: %s', requested_path.__str__())
  778.         for path in self.paths:
  779.             local_path = urlparse.urlsplit(path)
  780.             logging.debug('Matching local url: %s', local_path.__str__())
  781.             if requested_path.hostname == local_path.hostname and requested_path.port == local_path.port and requested_path.path == local_path.path:
  782.                 return True
  783.         return False
  784.  
  785.     def find_metadata_dir(self, dirname):
  786.         """Searches for the specified directory name starting with the current directory and going upwards the directory tree"""
  787.         cwd = os.getcwd()
  788.         if os.path.exists(os.path.join(cwd, dirname)):
  789.             return True
  790.         tmp = os.path.abspath(os.path.join(cwd, os.pardir))
  791.         while tmp is not None and tmp != cwd:
  792.             cwd = tmp
  793.             if os.path.exists(os.path.join(cwd, dirname)):
  794.                 return True
  795.             tmp = os.path.abspath(os.path.join(cwd, os.pardir))
  796.         return False
  797.  
  798. class FileSource(PatchSource):
  799.     """Gets the patch from the file specified by the commandline"""
  800.     def is_active(self):
  801.         return self.configuration.diff_file
  802.  
  803.     def get_patch(self):
  804.         path = self.configuration.diff_file
  805.         if not os.path.isfile(path):
  806.             print("No such file %s" % path)
  807.             return None
  808.  
  809.         with file(path) as f:
  810.             return f.read()
  811.  
  812.     def __str__(self): return 'file %s' % self.configuration.diff_file
  813.  
  814. class StdInSource(PatchSource):
  815.     """Gets a patch from stdin"""
  816.     def is_active(self):
  817.         return not sys.stdin.isatty()
  818.  
  819.     def get_patch(self):
  820.         return sys.stdin.read()
  821.  
  822.     def __str__(self):
  823.         return 'standard input'
  824.  
  825. class HgSource(PatchSource):
  826.     """Gets a patch from a mercurial repository"""
  827.  
  828.     def is_active(self):
  829.         return self.find_metadata_dir('.hg')
  830.  
  831.     def get_patch(self):
  832.         output = check_output([self.configuration.executables[self.executable()], 'diff'])
  833.         return output
  834.  
  835.     def __str__(self):
  836.         return 'Mercurial'
  837.  
  838.     def executable(self):
  839.         return 'hg'
  840.  
  841.     def validate_executable(self):
  842.         check_output([self.configuration.executables[self.executable()], 'help'])
  843.  
  844.     def load_paths(self):
  845.         paths_output = check_output([self.configuration.executables[self.executable()], 'paths']).splitlines()
  846.         if paths_output:
  847.             for path in paths_output:
  848.                 path_split = path.split('=')
  849.                 if len(path_split) > 1:
  850.                     self.paths.append(path_split[1].strip())
  851.  
  852.     def matches_repository(self, repository_data):
  853.         return repository_data['type'] == 'hg' and self.matches_url(repository_data['location'])
  854.  
  855.  
  856. class GitSource(PatchSource):
  857.     """Gets a patch from a git repository"""
  858.     def is_active(self):
  859.         return self.find_metadata_dir('.git')
  860.  
  861.     def get_patch(self):
  862.         output = check_output([self.configuration.executables[self.executable()], 'diff', '--cached', '--no-prefix'])
  863.         if not output:
  864.             output = check_output([self.configuration.executables[self.executable()], 'diff', '--no-prefix'])
  865.             if output:
  866.                 print('No staged changes were found in the repository, creating a review for all unstaged changes.')
  867.  
  868.         return output
  869.  
  870.     def __str__(self):
  871.         return 'Git'
  872.  
  873.     def executable(self):
  874.         return 'git'
  875.  
  876.     def validate_executable(self):
  877.         check_output([self.configuration.executables[self.executable()], 'help'])
  878.  
  879.     def load_paths(self):
  880.         paths_output = check_output([self.configuration.executables[self.executable()], 'remote', '-v']).splitlines()
  881.         if paths_output:
  882.             for path in paths_output:
  883.                 path_split = path.split()
  884.                 if len(path_split) > 1:
  885.                     self.paths.append(path_split[1].strip())
  886.  
  887.     def matches_repository(self, repository_data):
  888.         return repository_data['type'] == 'git' and self.matches_url(repository_data['location'])
  889.  
  890.  
  891. class SvnSource(PatchSource):
  892.     """Gets a patch from a subversion repository"""
  893.     def is_active(self):
  894.         return self.find_metadata_dir('.svn')
  895.  
  896.     def get_patch(self):
  897.         try:
  898.             return check_output([self.configuration.executables[self.executable()], 'diff'])
  899.         except CalledProcessError as e:
  900.             print('svn diff returned error: %s' % e.error.strip())
  901.             return None
  902.  
  903.     def __str__(self):
  904.         return 'Subversion'
  905.  
  906.     def executable(self):
  907.         return 'svn'
  908.  
  909.     def validate_executable(self):
  910.         check_output([self.configuration.executables[self.executable()], 'help'])
  911.  
  912.     def load_paths(self):
  913.         info_output = check_output([self.configuration.executables[self.executable()], 'info']).splitlines()
  914.         if info_output:
  915.             for line in info_output:
  916.                 if line.startswith('URL:'):
  917.                     line_split = line.split()
  918.                     if (len(line_split)) > 1:
  919.                         self.paths.append(line_split[1].strip())
  920.                     break
  921.  
  922.     def matches_repository(self, repository_data):
  923.         if not repository_data['type'] == 'svn':
  924.             return False
  925.         remote_url = (repository_data['url'].rstrip('/') + '/' + repository_data['path']).rstrip('/')
  926.         logging.debug('Matching remote url %s', remote_url)
  927.         return self.paths[0].startswith(remote_url)
  928.  
  929.  
  930. class CvsSource(PatchSource):
  931.     """Gets a patch from a CVS repository"""
  932.     def is_active(self):
  933.         try:
  934.             cmd = [self.configuration.executables[self.executable()], 'status']
  935.             check_output(cmd)
  936.             return True
  937.         except (OSError, CalledProcessError):
  938.             return False
  939.  
  940.     def get_patch(self):
  941.         try:
  942.             return check_output([self.configuration.executables[self.executable()], 'diff', '-N', '-U', '10000'])
  943.         except CalledProcessError as e:
  944.             return e.output
  945.  
  946.     def __str__(self):
  947.         return 'CVS'
  948.  
  949.     def executable(self):
  950.         return 'cvs'
  951.  
  952.     def load_paths(self):
  953.         try:
  954.             with open('CVS/Root') as root_file:
  955.                 self.paths.append(root_file.read().rstrip('/\n'))
  956.         except IOError as e:
  957.             logging.debug('Failed getting file root file: %s', e)
  958.  
  959.         if 'CVSROOT' in os.environ:
  960.             self.paths.append(os.environ['CVSROOT'].rstrip('/'))
  961.  
  962.     def matches_repository(self, repository_data):
  963.         return repository_data['type'] == 'cvs' and self.matches_url(repository_data['dir'].rstrip('/'))
  964.  
  965.  
  966. class P4Source(PatchSource):
  967.     def __init__(self, configuration):
  968.         super(P4Source, self).__init__(configuration)
  969.         self.p4info = {}
  970.  
  971.     def is_active(self):
  972.         """Gets a patch from a perforce repository."""
  973.         try:
  974.             check_output([self.configuration.executables[self.executable()]])
  975.         except (OSError, CalledProcessError):
  976.             return False
  977.  
  978.         self.get_client_info()
  979.  
  980.         clientRoot = self.p4info['Client root']
  981.         if not clientRoot:
  982.             logging.debug('Cannot find Perforce Client root while executing \'p4 info\'')
  983.             return False
  984.  
  985.         return (os.path.normcase(os.getcwd())).startswith(os.path.normcase(clientRoot))
  986.  
  987.     def get_client_info(self):
  988.         info = check_output([self.configuration.executables[self.executable()], 'info']).splitlines()
  989.         for line in info:
  990.             line_split = line.split(':', 1)
  991.             if len(line_split) > 1:
  992.                 self.p4info[line_split[0].strip()] = line_split[1].strip()
  993.         logging.debug('p4info: %s ', self.p4info)
  994.  
  995.     def get_path(self, file):
  996.         output = check_output([self.configuration.executables[self.executable()], 'fstat', '-T', 'clientFile', file])
  997.         return output[len('... clientFile '):].rstrip()
  998.  
  999.     def get_add(self, file, rev):
  1000.         f = open(self.get_path(file))
  1001.         lines = f.read().replace('\xc2\x85', '\n').splitlines()
  1002.         f.close()
  1003.  
  1004.         output = '--- /dev/null\n' + \
  1005.                  '+++ ' + file + '\t(revision ' + rev + ')\n' + \
  1006.                  '@@ -0,0 +1,' + str(len(lines)) + ' @@\n'
  1007.         output += '+' + '\n+'.join(lines)
  1008.         return output
  1009.  
  1010.     def get_delete(self, file, rev):
  1011.         lines = check_output([self.configuration.executables[self.executable()], 'print', '-q', file + '#' + rev]).splitlines()
  1012.  
  1013.         output = '--- ' + file + '\t(revision ' + rev + ')\n' + \
  1014.                  '+++ /dev/null\n' + \
  1015.                  '@@ -1,' + str(len(lines)) + ' +0,0 @@\n'
  1016.         output += '-' + '\n-'.join(lines)
  1017.         return output
  1018.  
  1019.     def get_patch(self):
  1020.         p4 = self.configuration.executables[self.executable()]
  1021.         output = check_output([p4, 'diff', '-dcu'])
  1022.  
  1023.         opened = check_output([p4, 'opened']).splitlines()
  1024.  
  1025.         pattern = re.compile('([^#]+)#([\\d]+) - ([^\\s]+)[^\\(](.*)')
  1026.         for line in opened:
  1027.             match = pattern.match(line)
  1028.             if match:
  1029.                 file = match.group(1)
  1030.                 rev = match.group(2)
  1031.                 change = match.group(3)
  1032.                 fileType = match.group(4)
  1033.                 if change == 'add':
  1034.                     output += '\n' + self.get_add(file, rev)
  1035.                 elif change == 'delete':
  1036.                     output += '\n' + self.get_delete(file, rev)
  1037.  
  1038.         return output
  1039.  
  1040.     def __str__(self):
  1041.         return 'Perforce'
  1042.  
  1043.     def executable(self):
  1044.         return 'p4'
  1045.  
  1046.     def load_paths(self):
  1047.         where_output = check_output([self.configuration.executables[self.executable()], 'where']).splitlines()
  1048.         for where in where_output:
  1049.             where_split = where.split()
  1050.             if len(where_split) == 3 and not where_split[0].startswith('-') and where_split[1].startswith('//%s' % self.p4info['Client name']):
  1051.                 self.paths.append(where_split[0])
  1052.  
  1053.     def matches_repository(self, repository_data):
  1054.         if not repository_data['type'] == 'p4': return False
  1055.         remote_address = '%s:%s' % (repository_data['server'], repository_data['port'])
  1056.         logging.debug('Matching remote url: %s', remote_address)
  1057.         if self.p4info.get('Server address') == remote_address or self.p4info.get('P4Sandbox broker port') == remote_address:
  1058.             for path in self.paths:
  1059.                 logging.debug('Matching path: %s', path)
  1060.                 if path.startswith(repository_data['path']):
  1061.                     return True
  1062.  
  1063.         return False
  1064.  
  1065. def main():
  1066.     """
  1067.    Runs the script to create a review
  1068.    """
  1069.  
  1070.     # read commandline
  1071.     command_line = CommandLine()
  1072.     args = command_line.parse_args()
  1073.  
  1074.     # parse/fill config
  1075.     config_file = ConfigFile()
  1076.     configuration = Configuration().fill_from_args(args).fill_from_config_file(config_file).fill_from_defaults().fill_interactively()
  1077.  
  1078.     # set up authentication, check for updates
  1079.     rest = CrucibleRest(configuration)
  1080.     rest.check_connection()
  1081.     config_file.store_configuration(configuration)
  1082.  
  1083.     if rest.is_script_update_available():
  1084.         print('An updated version of this script is available. Visit %s to download it' % (configuration.url + '/plugins/servlet/viewReviewCLI'))
  1085.  
  1086.     # get a patch
  1087.     patch, source = PatchSource(configuration).load_patch()
  1088.     if not patch:
  1089.         Console.error('Failed to get a patch%s. Please make sure to call the script from an SCM directory with local changes, or pipe in some input.'
  1090.               % (' from %s' % source if source else ''))
  1091.         sys.exit(1)
  1092.  
  1093.     # create or update the review
  1094.     if not configuration.repository and not configuration.no_anchor:
  1095.         detected_repository = rest.find_repository(source)
  1096.         if detected_repository:
  1097.             configuration.repository = detected_repository
  1098.             print('Detected Crucible repository %s. Use --repository or --noanchor options to override' % configuration.repository)
  1099.         else:
  1100.             print('No matching FishEye repository detected')
  1101.             configuration.choose_anchor(rest.get_repositories())
  1102.  
  1103.     configuration.last_project = rest.get_last_project()
  1104.     configuration.fill_interactively(get_id=True)
  1105.  
  1106.     review = rest.get_review(configuration.id)
  1107.     if review:
  1108.         rest.add_to_review(patch, review)
  1109.         sys.exit(0)
  1110.  
  1111.     project = rest.get_project(configuration.id)
  1112.     if project:
  1113.         configuration.fill_interactively(get_title=True, get_reviewers=True)
  1114.         rest.create_review(patch, project)
  1115.         sys.exit(0)
  1116.  
  1117.     Console.error('Failed to find a review or project with an id %s, make sure the id is correct.' % configuration.id)
  1118.     sys.exit(1)
  1119.  
  1120. if __name__ == '__main__':
  1121.     main()
Add Comment
Please, Sign In to add comment