diff --git a/bugz/cli.py b/bugz/cli.py index 9ef1dc3..a78a9d9 100644 --- a/bugz/cli.py +++ b/bugz/cli.py @@ -17,7 +17,9 @@ """ +import datetime import getpass +import mimetypes import os import re import subprocess @@ -36,7 +38,7 @@ from bugz.settings import Settings from bugz.exceptions import BugzError from bugz.log import log_error, log_info -from bugz.utils import block_edit, get_content_type +from bugz.utils import block_edit def check_bugz_token(): @@ -230,6 +232,14 @@ def prompt_for_bug(settings): log_info('Append command (optional): %s' % settings.append_command) +def parsetime(when): + return datetime.datetime.strptime(str(when), '%Y%m%dT%H:%M:%S') + + +def printtime(dt, settings): + return dt.strftime(settings.timeformat) + + def show_bug_info(bug, settings): FieldMap = { 'alias': 'Alias', @@ -245,20 +255,23 @@ def show_bug_info(bug, settings): 'severity': 'Severity', 'target_milestone': 'TargetMilestone', 'assigned_to': 'AssignedTo', + 'assigned_to_detail': 'AssignedTo', 'url': 'URL', 'whiteboard': 'Whiteboard', 'keywords': 'Keywords', 'depends_on': 'dependsOn', 'blocks': 'Blocks', 'creation_time': 'Reported', - 'creator': 'Reporter', + 'creator_detail': 'Reporter', 'last_change_time': 'Updated', - 'cc': 'CC', + 'cc_detail': 'CC', 'see_also': 'See Also', } - SkipFields = ['assigned_to_detail', 'cc_detail', 'creator_detail', 'id', - 'is_confirmed', 'is_creator_accessible', 'is_cc_accessible', - 'is_open', 'update_token'] + SkipFields = ['assigned_to', 'cc', 'creator', 'id', 'is_confirmed', + 'is_creator_accessible', 'is_cc_accessible', 'is_open', + 'update_token'] + TimeFields = ['last_change_time', 'creation_time'] + user_detail = {} for field in bug: if field in SkipFields: @@ -267,8 +280,18 @@ def show_bug_info(bug, settings): desc = FieldMap[field] else: desc = field - value = bug[field] - if field in ['cc', 'see_also']: + if field in TimeFields: + value = printtime(parsetime(bug[field]), settings) + else: + value = bug[field] + if field in ['assigned_to_detail', 'creator_detail']: + print('%-12s: %s <%s>' % (desc, value['real_name'], value['email'])) + user_detail[value['email']] = value + elif field == 'cc_detail': + for cc in value: + print('%-12s: %s <%s>' % (desc, cc['real_name'], cc['email'])) + user_detail[cc['email']] = cc + elif field == 'see_also': for x in value: print('%-12s: %s' % (desc, x)) elif isinstance(value, list): @@ -294,19 +317,71 @@ def show_bug_info(bug, settings): params = {'ids': [bug['id']]} bug_comments = settings.call_bz(settings.bz.Bug.comments, params) bug_comments = bug_comments['bugs']['%s' % bug['id']]['comments'] - print('%-12s: %d' % ('Comments', len(bug_comments))) + for comment in bug_comments: + comment['when'] = parsetime(comment['time']) + del comment['time'] + comment['who'] = comment['creator'] + del comment['creator'] + bug_history = settings.call_bz(settings.bz.Bug.history, params) + assert(bug_history['bugs'][0]['id'] == bug['id']) + bug_history = bug_history['bugs'][0]['history'] + for change in bug_history: + change['when'] = parsetime(change['when']) + bug_comments += bug_history + bug_comments.sort(key=lambda c: (c['when'], 'changes' in c)) print() i = 0 wrapper = textwrap.TextWrapper(width=settings.columns, break_long_words=False, break_on_hyphens=False) for comment in bug_comments: - who = comment['creator'] - when = comment['time'] + # Header, who & when + if comment == bug_comments[0] or \ + prev['when'] != comment['when'] or \ + prev['who'] != comment['who']: + if comment['who'] in user_detail: + who = '%s <%s>' % ( + user_detail[comment['who']]['real_name'], + comment['who']) + else: + who = comment['who'] + when = comment['when'] + header_left = '%s %s' % (who, printtime(when, settings)) + if i == 0: + header_right = 'Description' + elif 'changes' in comment: + header_right = '' + else: + header_right = '[Comment %d]' % i + space = settings.columns - len(header_left) - \ + len(header_right) - 3 + if space < 0: + space = 0 + print(header_left, ' ' * space, header_right) + print('-' * (settings.columns - 1)) + + # A change from Bug.history + if 'changes' in comment: + for change in comment['changes']: + if change['field_name'] in FieldMap: + desc = FieldMap[change['field_name']] + else: + desc = change['field_name'] + if change['removed'] and change['added']: + print('%s: %s → %s' % (desc, change['removed'], + change['added'])) + elif change['added']: + print('%s: %s' % (desc, change['added'])) + elif change['removed']: + print('REMOVED %s: %s ' % (desc, change['removed'])) + else: + print(change) + prev = comment + print() + continue + + # A comment from Bug.comments what = comment['text'] - print('[Comment #%d] %s : %s' % (i, who, when)) - print('-' * (settings.columns - 1)) - if what is None: what = '' @@ -318,6 +393,7 @@ def show_bug_info(bug, settings): for shortline in wrapper.wrap(line): print(shortline) print() + prev = comment i += 1 @@ -333,8 +409,19 @@ def attach(settings): if not os.path.exists(filename): raise BugzError('File not found: %s' % filename) + if is_patch is None and \ + (filename.endswith('.diff') or filename.endswith('.patch')): + content_type = 'text/plain' + is_patch = 1 + if content_type is None: - content_type = get_content_type(filename) + content_type = mimetypes.guess_type(filename)[0] + + if content_type is None: + if is_patch is None: + content_type = 'application/octet-stream' + else: + content_type = 'text/plain' if comment is None: comment = block_edit('Enter optional long description of attachment') @@ -363,33 +450,55 @@ def attach(settings): def attachment(settings): - """ Download or view an attachment given the id.""" - log_info('Getting attachment %s' % settings.attachid) + """ Download or view an attachment(s) given the attachment or bug id.""" params = {} - params['attachment_ids'] = [settings.attachid] + if hasattr(settings, 'bug'): + params['ids'] = [settings.id] + log_info('Getting attachment(s) for bug %s' % settings.id) + else: + params['attachment_ids'] = [settings.id] + log_info('Getting attachment %s' % settings.id) check_auth(settings) + results = settings.call_bz(settings.bz.Bug.attachments, params) - result = settings.call_bz(settings.bz.Bug.attachments, params) - result = result['attachments'][settings.attachid] - view = hasattr(settings, 'view') + if hasattr(settings, 'bug'): + results = results['bugs'][settings.id] + else: + results = [ results['attachments'][settings.id] ] + + if hasattr(settings, 'patch_only'): + results = list(filter(lambda x : x['is_patch'], results)) + + if hasattr(settings, 'skip_obsolete'): + results = list(filter(lambda x : not x['is_obsolete'], results)) + + if not results: + return + + if hasattr(settings, 'most_recent'): + results = [ results[-1] ] + view = hasattr(settings, 'view') action = {True: 'Viewing', False: 'Saving'} - log_info('%s attachment: "%s"' % - (action[view], result['file_name'])) - safe_filename = os.path.basename(re.sub(r'\.\.', '', + + for result in results: + log_info('%s%s attachment: "%s"' % (action[view], + ' obsolete' if result['is_obsolete'] else '', + result['file_name'])) + safe_filename = os.path.basename(re.sub(r'\.\.', '', result['file_name'])) - if view: - print(result['data'].data.decode('utf-8')) - else: - if os.path.exists(result['file_name']): - raise RuntimeError('Filename already exists') + if view: + print(result['data'].data.decode('utf-8')) + else: + if os.path.exists(result['file_name']): + raise RuntimeError('Filename already exists') - fd = open(safe_filename, 'wb') - fd.write(result['data'].data) - fd.close() + fd = open(safe_filename, 'wb') + fd.write(result['data'].data) + fd.close() def get(settings): @@ -415,14 +524,13 @@ def modify(settings): except IOError as error: raise BugzError('unable to read file: %s: %s' % (settings.comment_from, error)) + else: + settings.comment = '' if hasattr(settings, 'assigned_to') and \ hasattr(settings, 'reset_assigned_to'): raise BugzError('--assigned-to and --unassign cannot be used together') - if hasattr(settings, 'comment_editor'): - settings.comment = block_edit('Enter comment:') - params = {} params['ids'] = [settings.bugid] if hasattr(settings, 'alias'): @@ -453,10 +561,6 @@ def modify(settings): if 'cc' not in params: params['cc'] = {} params['cc']['remove'] = settings.cc_remove - if hasattr(settings, 'comment'): - if 'comment' not in params: - params['comment'] = {} - params['comment']['body'] = settings.comment if hasattr(settings, 'component'): params['component'] = settings.component if hasattr(settings, 'dupe_of'): @@ -522,9 +626,42 @@ def modify(settings): params['status'] = 'RESOLVED' params['resolution'] = 'INVALID' + check_auth(settings) + + if hasattr(settings, 'comment_editor'): + quotes='' + if hasattr(settings, 'quote'): + bug_comments = settings.call_bz(settings.bz.Bug.comments, params) + bug_comments = bug_comments['bugs']['%s' % settings.bugid]\ + ['comments'][-settings.quote:] + wrapper = textwrap.TextWrapper(width=settings.columns, + break_long_words=False, + break_on_hyphens=False) + for comment in bug_comments: + what = comment['text'] + if what is None: + continue + who = comment['creator'] + when = parsetime(comment['time']) + quotes += 'On %s, %s wrote:\n' % (printtime(when, settings), + who) + for line in what.splitlines(): + if len(line) < settings.columns: + quotes += '> %s\n' % line + else: + for shortline in wrapper.wrap(line): + quotes += '> %s\n' % shortline + settings.comment = block_edit('Enter comment:', + comment_from=settings.comment, + quotes=quotes) + + if hasattr(settings, 'comment'): + if 'comment' not in params: + params['comment'] = {} + params['comment']['body'] = settings.comment + if len(params) < 2: raise BugzError('No changes were specified') - check_auth(settings) result = settings.call_bz(settings.bz.Bug.update, params) for bug in result['bugs']: changes = bug['changes'] diff --git a/bugz/cli_argparser.py b/bugz/cli_argparser.py index 48fd964..786ce7b 100644 --- a/bugz/cli_argparser.py +++ b/bugz/cli_argparser.py @@ -14,6 +14,8 @@ def make_arg_parser(): 'configuration file') parser.add_argument('-b', '--base', help='base URL of Bugzilla') + parser.add_argument('-t', '--timeformat', + help='Time format (default: %%+ UTC), see strftime(3)') parser.add_argument('-u', '--user', help='username') parser.add_argument('-p', '--password', @@ -78,13 +80,25 @@ def make_arg_parser(): attachment_parser = subparsers.add_parser('attachment', argument_default=argparse.SUPPRESS, - help='get an attachment ' + help='get an attachment(s) ' 'from Bugzilla') - attachment_parser.add_argument('attachid', - help='the ID of the attachment') + attachment_parser.add_argument('id', + help='the ID of the attachment or bug') + attachment_parser.add_argument('-b', '--bug', + action='store_true', + help='the ID is a bug') + attachment_parser.add_argument('-r', '--most-recent', + action='store_true', + help='get only most recent attachment') + attachment_parser.add_argument('-p', '--patch-only', + action='store_true', + help='get only patch attachment(s)') + attachment_parser.add_argument('-o', '--skip-obsolete', + action='store_true', + help='get only not obsolete attachment(s)') attachment_parser.add_argument('-v', '--view', action="store_true", - help='print attachment rather than save') + help='print attachment(s) rather than save') attachment_parser.set_defaults(func=bugz.cli.attachment) connections_parser = subparsers.add_parser('connections', @@ -190,6 +204,9 @@ def make_arg_parser(): help='change the priority for this bug') modify_parser.add_argument('--product', help='change the product for this bug') + modify_parser.add_argument('-Q', '--quote', + action='count', + help='quote most recent comment(s) with -C') modify_parser.add_argument('-r', '--resolution', help='set new resolution ' '(if status = RESOLVED)') diff --git a/bugz/settings.py b/bugz/settings.py index c45481c..5eac42a 100644 --- a/bugz/settings.py +++ b/bugz/settings.py @@ -43,6 +43,14 @@ def __init__(self, args, config): self.connection, 'component') + if not hasattr(self, 'timeformat'): + if config.has_option(self.connection, 'timeformat'): + self.timeformat = get_config_option(config.get, + self.connection, + 'timeformat') + else: + self.timeformat = '%+ UTC' + if not hasattr(self, 'user'): if config.has_option(self.connection, 'user'): self.user = get_config_option(config.get, diff --git a/bugz/utils.py b/bugz/utils.py index f6d7996..7823896 100644 --- a/bugz/utils.py +++ b/bugz/utils.py @@ -1,4 +1,3 @@ -import mimetypes import os import re import sys @@ -23,10 +22,6 @@ # -def get_content_type(filename): - return mimetypes.guess_type(filename)[0] or 'application/octet-stream' - - def raw_input_block(): """ Allows multiple line input until a Ctrl+D is detected. @@ -72,7 +67,8 @@ def terminal_width(): return width -def launch_editor(initial_text, comment_from='', comment_prefix='BUGZ:'): +def launch_editor(initial_text, comment_from='', comment_prefix='BUGZ:', + quotes=''): """Launch an editor with some default text. Lifted from Mercurial 0.9. @@ -80,6 +76,7 @@ def launch_editor(initial_text, comment_from='', comment_prefix='BUGZ:'): """ (fd, name) = tempfile.mkstemp("bugz") f = os.fdopen(fd, "w") + f.write(quotes) f.write(comment_from) f.write(initial_text) f.close() @@ -98,7 +95,7 @@ def launch_editor(initial_text, comment_from='', comment_prefix='BUGZ:'): return '' -def block_edit(comment, comment_from=''): +def block_edit(comment, comment_from='', quotes=''): editor = (os.environ.get('BUGZ_EDITOR') or os.environ.get('EDITOR')) if not editor: @@ -109,7 +106,8 @@ def block_edit(comment, comment_from=''): initial_text = '\n'.join(['BUGZ: %s' % line for line in comment.splitlines()]) new_text = launch_editor(BUGZ_COMMENT_TEMPLATE % initial_text, - comment_from) + comment_from=comment_from, + quotes=quotes) if new_text.strip(): return new_text