import collections import logging import datetime import re import argparse import yaml import requests from os.path import exists, expanduser, expandvars from shutil import copyfile from six.moves import input from six import iteritems from dateutil.relativedelta import relativedelta from reviewrot.gerritstack import GerritService from reviewrot.githubstack import GithubService from reviewrot.gitlabstack import GitlabService from reviewrot.pagurestack import PagureService from reviewrot.phabricatorstack import PhabricatorService from reviewrot.basereview import Age log = logging.getLogger(__name__) # Valid values of choices for arguments CHOICES = { 'format': ['oneline', 'indented', 'json'], 'sort': ['submitted', 'updated', 'commented'], } def get_git_service(git): """ Returns git service as per requested. Args: git (str): String indicating git service requested. Returns: Returns desired git service """ if git == "github": return GithubService() elif git == "gitlab": return GitlabService() elif git == "pagure": return PagureService() elif git == "gerrit": return GerritService() elif git == "phabricator": return PhabricatorService() else: raise ValueError('requested git service %s is not valid' % (git)) def get_arguments(cli_arguments, config): """ Parse the arguments provided in configuration file and command line arguments Args: cli_arguments (argparse.Namespace): Arguments provided by command line interface config (dict): Configuration from file Returns: arguments (dict): Returns the parsed arguments """ config_arguments = config.get('arguments', {}) if config_arguments is None: raise ValueError( 'Argument section in config can\'t be empty,' ' remove the section or add arguments' ) config_mailer = config.get('mailer', {}) config_irc = config.get('irc', {}) parsed_arguments = {} command_line_args = vars(cli_arguments) for arg in command_line_args: if command_line_args.get(arg) is not None: parsed_arguments[arg] = command_line_args.get(arg) for argument in config_arguments: # Explicitly commandline arguments cannot be specified # false or none. if ( command_line_args.get(argument) is None or command_line_args.get(argument) is False ): config_value = config_arguments.get(argument) if is_valid_choice(argument, config_value): parsed_arguments[argument] = config_value else: log.warn( "Invalid choice '%s' provided for '%s' in" " config file" % (config_value, argument) ) # --debug, --reverse and --insecure or --cacert flags are used to # specify arguments from command line. If not specified, value will # be False or None. In this case, if these arguments are specified in # config file, then the value will be taken from the config file. if config_arguments.get('debug'): parsed_arguments['debug'] = True if config_arguments.get('reverse'): parsed_arguments['reverse'] = True email_in_config = config_arguments.get('email') if email_in_config: parsed_arguments['email'] = [ email.strip() for email in email_in_config.split(',') ] irc_in_config = config_arguments.get('irc') if irc_in_config: parsed_arguments['irc'] = [ channel.strip() for channel in irc_in_config.split(',') ] age_in_config = config_arguments.get('age') if age_in_config: values = age_in_config.split(" ") parsed_arguments['age'] = ParseAge.parse(values) insecure = cli_arguments.insecure or config_arguments.get('insecure') cacert = cli_arguments.cacert or config_arguments.get('cacert') if insecure and cacert: raise ValueError("Certificate file can't be used with insecure flag") if insecure: parsed_arguments['ssl_verify'] = False requests.packages.urllib3.disable_warnings( requests.packages.urllib3.exceptions.InsecureRequestWarning ) elif cacert: cacert = expanduser(expandvars(cacert)) if not exists(cacert): raise IOError("No CA certificate file found at %s" % cacert) parsed_arguments['ssl_verify'] = cacert else: parsed_arguments['ssl_verify'] = True format = parsed_arguments.get('format') show_last_comment = parsed_arguments.get('show_last_comment') if (format == 'oneline' and show_last_comment is not None): raise ValueError( '{} format doesn\'t support last comment functionality'.format( format ) ) irc = parsed_arguments.get('irc') email = parsed_arguments.get('email') if email and format: raise ValueError( 'No format should be specified when selecting email output' ) if email and any(property not in config_mailer for property in ['server', 'sender']): raise ValueError( 'Missing mailer configuration.' ' Check examples/sampleinput_email.yaml ' 'for correct configuration.' ) if irc and format: raise ValueError( 'No format should be specified when selecting irc output' ) if irc and any(property not in config_irc for property in ['server', 'port']): raise ValueError( 'Missing irc configuration.' ' Check examples/sampleinput_irc.yaml ' 'for correct configuration.' ) return parsed_arguments class ParseAge(argparse.Action): """ Custom argument parsing class that handles the --age argument """ def __call__(self, parser, namespace, values, option_string=None): setattr(namespace, self.dest, self.parse(values)) @staticmethod def parse(values): if len(values) < 2: raise ValueError("Missing arguments") if values[0] not in ['older', 'newer']: raise ValueError("Wrong or missing state, only older/newer is allowed") state = values[0] values = values[1:] regex = re.compile(r'^(?P<value>\d+)(?P<unit>y|m|d|h|min)$') parts = {} unit_mapping = { "y": "years", "m": "months", "d": "days", "h": "hours", "min": "minutes", } for v in values: part = regex.search(v) if part is None: raise ValueError("Invalid unit " + v) unit = unit_mapping[part.group('unit')] parts[unit] = int(part.group('value')) delta = relativedelta(**parts) date = datetime.datetime.now() - delta return Age(date=date, state=state) def parse_cli_args(args): """ Parsing of command line arguments Args: args (list): arguments passed to review-rot on command line Returns: parsed arguments (argparse.Namespace): Returns the parsed arguments """ parser = argparse.ArgumentParser( description='Lists pull/merge/change requests for github, gitlab,' ' pagure, gerrit and phabricator') default_config = expanduser('~/.reviewrot.yaml') parser.add_argument('-c', '--config', default=default_config, help='Configuration file to use') parser.add_argument('--age', default=None, nargs="+", action=ParseAge, help='Filter pull request based on their relative age', metavar=('{older,newer}', '#y #m #d #h #min')) parser.add_argument('-f', '--format', default=None, choices=CHOICES['format'], help='Choose from one of a few different styles') parser.add_argument('--show-last-comment', nargs='?', metavar="DAYS", default=None, const=0, type=int, help='Show text of last comment and ' 'filter out pull requests in which ' 'last comments are newer than ' 'specified number of days') parser.add_argument('--reverse', action='store_true', help='Display results with the most recent first') # With --sort argument added, --comment-sort is kept for backwards # compatibility. Use --sort commented instead. parser.add_argument('--comment-sort', action='store_true', help=argparse.SUPPRESS) # Default value is left as None to ensure that the argument passed here # takes precedence over the value in configuration arguments. parser.add_argument('--sort', default=None, choices=CHOICES['sort'], help=('Display results sorted by the chosen event ' 'time. Defaults to ' '{}').format(CHOICES['sort'][0])) parser.add_argument('--debug', action='store_true', help='Display debug logs on console') parser.add_argument('--email', nargs="+", default=None, help='send output to list of email adresses') parser.add_argument('--irc', nargs='+', metavar="CHANNEL", default=None, help='send output to list of irc channels') parser.add_argument('--ignore-wip', help='Omit WIP PRs/MRs from output', action='store_true') ssl_group = parser.add_argument_group('SSL') ssl_group.add_argument('-k', '--insecure', default=False, action='store_true', help='Disable SSL certificate verification ' '(not recommended)') ssl_group.add_argument('--cacert', default=None, help='Path to CA certificate to use for SSL ' 'certificate verification') return parser.parse_args(args) def is_valid_choice(argument, value): """ Checks if value is valid choice or not for given argument Args: value (str): argument value argument (str): argument as key Returns: Returns boolean value """ if CHOICES.get(argument) is None or value in CHOICES.get(argument): return True return False def load_config_file(config_path): """ Loads the configuration file from the user's home directory or user specified location Args: config_path (str): Path to the configuration file Returns: config(dict): Returns the configurations """ if not exists(config_path): raise RuntimeError("No config file found at %s" % config_path) # read input from the config file for pull requests config = load_ordered_config(config_path) if isinstance(config, list): # convert to new format config = dict(git_services=config, arguments=None) prompt = "Would you like to rewrite the config file in new " \ "format [y/n] :" input_choice = input(prompt) answer = str(input_choice).lower().strip() if answer == 'y' or answer == '': print # Take the backup of configuration file and # save the configurations in new format backup_path = config_path + '.backup' log.info("Creating back up at " + backup_path) copyfile(config_path, backup_path) log.info("Rewriting %r in new format!" % config_path) with open(config_path, 'w') as f: f.write(yaml.dump(config, default_flow_style=False)) return config def load_ordered_config(config_path): """ Loads the configuration in the same order as it's defined in yaml file, so that, while saving it in new format, order is maintained Args: config_path (str): Path to the configuration file Returns: config(dict): Returns the configurations in the defined ordered """ # To load data from yaml in ordered dict format _mapping_tag = yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG def dict_representer(dumper, data): return dumper.represent_mapping(_mapping_tag, iteritems(data)) def dict_constructor(loader, node): return collections.OrderedDict(loader.construct_pairs(node)) yaml.add_representer(collections.OrderedDict, dict_representer) yaml.add_constructor(_mapping_tag, dict_constructor) # format the output to print a blank scalar rather than null def represent_none(self, _): return self.represent_scalar('tag:yaml.org,2002:null', u'') yaml.add_representer(type(None), represent_none) # read input from home directory for pull requests with open(config_path, 'r') as f: config = yaml.safe_load(f) return config def remove_wip(results): """ Removes WIP reviews from results Args: results (list): list of BaseReview instances Returns: res (list): list of BaseReview instances with WIP reviews removed """ res = [] for result in results: match = re.match(r'^(\[WIP\]\s*|WIP:\s*|WIP\s+)+\s*', str(result.title), re.IGNORECASE) if not match: res.append(result) return res