1"""
2Python Bugzilla Interface
3
4Simple command-line interface to bugzilla to allow:
5 - searching
6 - getting bug info
7 - saving attachments
8
9Requirements
10------------
11- Python 3.3 or later
12- setuptools
13
14Classes
15-------
16 - BugzillaProxy - Server proxy for communication with Bugzilla
17
18"""
19
20import getpass
21import os
22import re
23import subprocess
24import sys
25import textwrap
26import xmlrpc.client
27
28try:
29    import readline
30except ImportError:
31    pass
32
33
34from bugz.cli_argparser import make_arg_parser
35from bugz.configfile import load_config
36from bugz.settings import Settings
37from bugz.exceptions import BugzError
38from bugz.log import log_error, log_info
39from bugz.utils import block_edit, get_content_type
40
41
42def check_bugz_token():
43    tokenFound = os.path.isfile(os.path.expanduser('~/.bugz_token')) or \
44        os.path.isfile(os.path.expanduser('~/.bugz_tokens'))
45    if not tokenFound:
46        return
47    print('This version of pybugz no longer supports tokens.')
48    print()
49    print('If the bugzilla you are accessing is 5.0 or newer, you can')
50    print('generate an api key by visiting the preferences section')
51    print('of the Bugzilla web interface.')
52    print('For bugzilla 3.6 or newer, you can use your username and password')
53    print()
54    print('Once you have decided how you want to authenticate,')
55    print('please configure the appropriate settings in ~/.bugzrc')
56    print('and remove ~/.bugz_token  and ~/.bugz_tokens')
57    print()
58    print('see man pybugz.d for ~/.bugzrc settings')
59    print('This decision was made because Bugzilla is deprecating tokens.')
60
61
62def login(settings):
63    """Authenticate a session.
64    """
65
66    if settings.skip_auth or hasattr(settings, 'key'):
67        return
68
69    # prompt for username if we were not supplied with it
70    if not hasattr(settings, 'user'):
71        log_info('No username given.')
72        settings.user = input('Username: ')
73
74    # prompt for password if we were not supplied with it
75    if not hasattr(settings, 'password'):
76        if not hasattr(settings, 'passwordcmd'):
77            log_info('No password given.')
78            settings.password = getpass.getpass()
79        else:
80            process = subprocess.Popen(settings.passwordcmd, shell=True,
81                                       stdout=subprocess.PIPE)
82            password, _ = process.communicate()
83            settings.password = password.splitlines()[0]
84
85
86def list_bugs(buglist, settings):
87    for bug in buglist:
88        bugid = bug['id']
89        status = bug['status']
90        priority = bug['priority']
91        severity = bug['severity']
92        assignee = bug['assigned_to'].split('@')[0]
93        desc = bug['summary']
94        line = '%s' % (bugid)
95        if hasattr(settings, 'show_status'):
96            line = '%s %-12s' % (line, status)
97        if hasattr(settings, 'show_priority'):
98            line = '%s %-12s' % (line, priority)
99        if hasattr(settings, 'show_severity'):
100            line = '%s %-12s' % (line, severity)
101        line = '%s %-20s' % (line, assignee)
102        line = '%s %s' % (line, desc)
103        print(line[:settings.columns])
104
105    log_info("%i bug(s) found." % len(buglist))
106
107
108def prompt_for_bug(settings):
109    """ Prompt for the information for a bug
110    """
111    log_info('Press Ctrl+C at any time to abort.')
112
113    if not hasattr(settings, 'product'):
114        product = None
115        while not product or len(product) < 1:
116            product = input('Enter product: ')
117        settings.product = product
118    else:
119        log_info('Enter product: %s' % settings.product)
120
121    if not hasattr(settings, 'component'):
122        component = None
123        while not component or len(component) < 1:
124            component = input('Enter component: ')
125        settings.component = component
126    else:
127        log_info('Enter component: %s' % settings.component)
128
129    if not hasattr(settings, 'version'):
130        line = input('Enter version (default: unspecified): ')
131        if len(line):
132            settings.version = line
133        else:
134            settings.version = 'unspecified'
135    else:
136        log_info('Enter version: %s' % settings.version)
137
138    if not hasattr(settings, 'summary'):
139        summary = None
140        while not summary or len(summary) < 1:
141            summary = input('Enter title: ')
142        settings.summary = summary
143    else:
144        log_info('Enter title: %s' % settings.summary)
145
146    if not hasattr(settings, 'description'):
147        line = block_edit('Enter bug description: ')
148        if len(line):
149            settings.description = line
150    else:
151        log_info('Enter bug description: %s' % settings.description)
152
153    if not hasattr(settings, 'op_sys'):
154        op_sys_msg = 'Enter operating system where this bug occurs: '
155        line = input(op_sys_msg)
156        if len(line):
157            settings.op_sys = line
158    else:
159        log_info('Enter operating system: %s' % settings.op_sys)
160
161    if not hasattr(settings, 'platform'):
162        platform_msg = 'Enter hardware platform where this bug occurs: '
163        line = input(platform_msg)
164        if len(line):
165            settings.platform = line
166    else:
167        log_info('Enter hardware platform: %s' % settings.platform)
168
169    if not hasattr(settings, 'priority'):
170        priority_msg = 'Enter priority (eg. Normal) (optional): '
171        line = input(priority_msg)
172        if len(line):
173            settings.priority = line
174    else:
175        log_info('Enter priority (optional): %s' % settings.priority)
176
177    if not hasattr(settings, 'severity'):
178        severity_msg = 'Enter severity (eg. normal) (optional): '
179        line = input(severity_msg)
180        if len(line):
181            settings.severity = line
182    else:
183        log_info('Enter severity (optional): %s' % settings.severity)
184
185    if not hasattr(settings, 'alias'):
186        alias_msg = 'Enter an alias for this bug (optional): '
187        line = input(alias_msg)
188        if len(line):
189            settings.alias = line
190    else:
191        log_info('Enter alias (optional): %s' % settings.alias)
192
193    if not hasattr(settings, 'assigned_to'):
194        assign_msg = 'Enter assignee (eg. liquidx@gentoo.org) (optional): '
195        line = input(assign_msg)
196        if len(line):
197            settings.assigned_to = line
198    else:
199        log_info('Enter assignee (optional): %s' % settings.assigned_to)
200
201    if not hasattr(settings, 'cc'):
202        cc_msg = 'Enter a CC list (comma separated) (optional): '
203        line = input(cc_msg)
204        if len(line):
205            settings.cc = re.split(r',\s*', line)
206    else:
207        log_info('Enter a CC list (optional): %s' % settings.cc)
208
209    if not hasattr(settings, 'url'):
210        url_msg = 'Enter a URL (optional): '
211        line = input(url_msg)
212        if len(line):
213            settings.url = line
214    else:
215        log_info('Enter a URL (optional): %s' % settings.url)
216
217    # fixme: groups
218
219    # fixme: status
220
221    # fixme: milestone
222
223    if not hasattr(settings, 'append_command'):
224        line = input('Append the output of the'
225                     ' following command (leave blank for none): ')
226        if len(line):
227            settings.append_command = line
228    else:
229        log_info('Append command (optional): %s' % settings.append_command)
230
231
232def show_bug_info(bug, settings):
233    FieldMap = {
234        'alias': 'Alias',
235        'summary': 'Title',
236        'status': 'Status',
237        'resolution': 'Resolution',
238        'product': 'Product',
239        'component': 'Component',
240        'version': 'Version',
241        'platform': 'Hardware',
242        'op_sys': 'OpSystem',
243        'priority': 'Priority',
244        'severity': 'Severity',
245        'target_milestone': 'TargetMilestone',
246        'assigned_to': 'AssignedTo',
247        'url': 'URL',
248        'whiteboard': 'Whiteboard',
249        'keywords': 'Keywords',
250        'depends_on': 'dependsOn',
251        'blocks': 'Blocks',
252        'creation_time': 'Reported',
253        'creator': 'Reporter',
254        'last_change_time': 'Updated',
255        'cc': 'CC',
256        'see_also': 'See Also',
257    }
258    SkipFields = ['assigned_to_detail', 'cc_detail', 'creator_detail', 'id',
259                  'is_confirmed', 'is_creator_accessible', 'is_cc_accessible',
260                  'is_open', 'update_token']
261
262    for field in bug:
263        if field in SkipFields:
264            continue
265        if field in FieldMap:
266            desc = FieldMap[field]
267        else:
268            desc = field
269        value = bug[field]
270        if field in ['cc', 'see_also']:
271            for x in value:
272                print('%-12s: %s' % (desc, x))
273        elif isinstance(value, list):
274            s = ', '.join(["%s" % x for x in value])
275            if s:
276                print('%-12s: %s' % (desc, s))
277        elif value is not None and value != '':
278            print('%-12s: %s' % (desc, value))
279
280    if not hasattr(settings, 'no_attachments'):
281        params = {'ids': [bug['id']]}
282        bug_attachments = settings.call_bz(settings.bz.Bug.attachments, params)
283        bug_attachments = bug_attachments['bugs']['%s' % bug['id']]
284        print('%-12s: %d' % ('Attachments', len(bug_attachments)))
285        print()
286        for attachment in bug_attachments:
287            aid = attachment['id']
288            desc = attachment['summary']
289            when = attachment['creation_time']
290            print('[Attachment] [%s] [%s]' % (aid, desc))
291
292    if not hasattr(settings, 'no_comments'):
293        params = {'ids': [bug['id']]}
294        bug_comments = settings.call_bz(settings.bz.Bug.comments, params)
295        bug_comments = bug_comments['bugs']['%s' % bug['id']]['comments']
296        print('%-12s: %d' % ('Comments', len(bug_comments)))
297        print()
298        i = 0
299        wrapper = textwrap.TextWrapper(width=settings.columns,
300                                       break_long_words=False,
301                                       break_on_hyphens=False)
302        for comment in bug_comments:
303            who = comment['creator']
304            when = comment['time']
305            what = comment['text']
306            print('[Comment #%d] %s : %s' % (i, who, when))
307            print('-' * (settings.columns - 1))
308
309            if what is None:
310                what = ''
311
312            # print wrapped version
313            for line in what.splitlines():
314                if len(line) < settings.columns:
315                    print(line)
316                else:
317                    for shortline in wrapper.wrap(line):
318                        print(shortline)
319            print()
320            i += 1
321
322
323def attach(settings):
324    """ Attach a file to a bug given a filename. """
325    filename = getattr(settings, 'filename', None)
326    content_type = getattr(settings, 'content_type', None)
327    bugid = getattr(settings, 'bugid', None)
328    summary = getattr(settings, 'summary', None)
329    is_patch = getattr(settings, 'is_patch', None)
330    comment = getattr(settings, 'comment', None)
331
332    if not os.path.exists(filename):
333        raise BugzError('File not found: %s' % filename)
334
335    if content_type is None:
336        content_type = get_content_type(filename)
337
338    if comment is None:
339        comment = block_edit('Enter optional long description of attachment')
340
341    if summary is None:
342        summary = os.path.basename(filename)
343
344    params = {}
345    params['ids'] = [bugid]
346
347    fd = open(filename, 'rb')
348    params['data'] = xmlrpc.client.Binary(fd.read())
349    fd.close()
350
351    params['file_name'] = os.path.basename(filename)
352    params['summary'] = summary
353    params['content_type'] = content_type
354    params['comment'] = comment
355    if is_patch is not None:
356        params['is_patch'] = is_patch
357    login(settings)
358    result = settings.call_bz(settings.bz.Bug.add_attachment, params)
359    attachid = result['ids'][0]
360    log_info('{0} ({1}) has been attached to bug {2}'.format(
361        filename, attachid, bugid))
362
363
364def attachment(settings):
365    """ Download or view an attachment given the id."""
366    log_info('Getting attachment %s' % settings.attachid)
367
368    params = {}
369    params['attachment_ids'] = [settings.attachid]
370
371    login(settings)
372
373    result = settings.call_bz(settings.bz.Bug.attachments, params)
374    result = result['attachments'][settings.attachid]
375    view = hasattr(settings, 'view')
376
377    action = {True: 'Viewing', False: 'Saving'}
378    log_info('%s attachment: "%s"' %
379             (action[view], result['file_name']))
380    safe_filename = os.path.basename(re.sub(r'\.\.', '',
381                                            result['file_name']))
382
383    if view:
384        print(result['data'].data.decode('utf-8'))
385    else:
386        if os.path.exists(result['file_name']):
387            raise RuntimeError('Filename already exists')
388
389        fd = open(safe_filename, 'wb')
390        fd.write(result['data'].data)
391        fd.close()
392
393
394def get(settings):
395    """ Fetch bug details given the bug id """
396    login(settings)
397
398    log_info('Getting bug %s ..' % settings.bugid)
399    params = {'ids': [settings.bugid]}
400    result = settings.call_bz(settings.bz.Bug.get, params)
401
402    for bug in result['bugs']:
403        show_bug_info(bug, settings)
404
405
406def modify(settings):
407    """Modify an existing bug (eg. adding a comment or changing resolution.)"""
408    if hasattr(settings, 'comment_from'):
409        try:
410            if settings.comment_from == '-':
411                settings.comment = sys.stdin.read()
412            else:
413                settings.comment = open(settings.comment_from, 'r').read()
414        except IOError as error:
415            raise BugzError('unable to read file: %s: %s' %
416                            (settings.comment_from, error))
417
418    if hasattr(settings, 'assigned_to') and \
419            hasattr(settings, 'reset_assigned_to'):
420        raise BugzError('--assigned-to and --unassign cannot be used together')
421
422    if hasattr(settings, 'comment_editor'):
423        settings.comment = block_edit('Enter comment:')
424
425    params = {}
426    params['ids'] = [settings.bugid]
427    if hasattr(settings, 'alias'):
428        params['alias'] = settings.alias
429    if hasattr(settings, 'assigned_to'):
430        params['assigned_to'] = settings.assigned_to
431    if hasattr(settings, 'blocks_add'):
432        if 'blocks' not in params:
433            params['blocks'] = {}
434        params['blocks']['add'] = settings.blocks_add
435    if hasattr(settings, 'blocks_remove'):
436        if 'blocks' not in params:
437            params['blocks'] = {}
438        params['blocks']['remove'] = settings.blocks_remove
439    if hasattr(settings, 'depends_on_add'):
440        if 'depends_on' not in params:
441            params['depends_on'] = {}
442        params['depends_on']['add'] = settings.depends_on_add
443    if hasattr(settings, 'depends_on_remove'):
444        if 'depends_on' not in params:
445            params['depends_on'] = {}
446        params['depends_on']['remove'] = settings.depends_on_remove
447    if hasattr(settings, 'cc_add'):
448        if 'cc' not in params:
449            params['cc'] = {}
450        params['cc']['add'] = settings.cc_add
451    if hasattr(settings, 'cc_remove'):
452        if 'cc' not in params:
453            params['cc'] = {}
454        params['cc']['remove'] = settings.cc_remove
455    if hasattr(settings, 'comment'):
456        if 'comment' not in params:
457            params['comment'] = {}
458        params['comment']['body'] = settings.comment
459    if hasattr(settings, 'component'):
460        params['component'] = settings.component
461    if hasattr(settings, 'dupe_of'):
462        params['dupe_of'] = settings.dupe_of
463    if hasattr(settings, 'deadline'):
464        params['deadline'] = settings.deadline
465    if hasattr(settings, 'estimated_time'):
466        params['estimated_time'] = settings.estimated_time
467    if hasattr(settings, 'remaining_time'):
468        params['remaining_time'] = settings.remaining_time
469    if hasattr(settings, 'work_time'):
470        params['work_time'] = settings.work_time
471    if hasattr(settings, 'groups_add'):
472        if 'groups' not in params:
473            params['groups'] = {}
474        params['groups']['add'] = settings.groups_add
475    if hasattr(settings, 'groups_remove'):
476        if 'groups' not in params:
477            params['groups'] = {}
478        params['groups']['remove'] = settings.groups_remove
479    if hasattr(settings, 'keywords_set'):
480        if 'keywords' not in params:
481            params['keywords'] = {}
482        params['keywords']['set'] = settings.keywords_set
483    if hasattr(settings, 'op_sys'):
484        params['op_sys'] = settings.op_sys
485    if hasattr(settings, 'platform'):
486        params['platform'] = settings.platform
487    if hasattr(settings, 'priority'):
488        params['priority'] = settings.priority
489    if hasattr(settings, 'product'):
490        params['product'] = settings.product
491    if hasattr(settings, 'resolution'):
492        if not hasattr(settings, 'dupe_of'):
493            params['resolution'] = settings.resolution
494    if hasattr(settings, 'see_also_add'):
495        if 'see_also' not in params:
496            params['see_also'] = {}
497        params['see_also']['add'] = settings.see_also_add
498    if hasattr(settings, 'see_also_remove'):
499        if 'see_also' not in params:
500            params['see_also'] = {}
501        params['see_also']['remove'] = settings.see_also_remove
502    if hasattr(settings, 'severity'):
503        params['severity'] = settings.severity
504    if hasattr(settings, 'status'):
505        if not hasattr(settings, 'dupe_of'):
506            params['status'] = settings.status
507    if hasattr(settings, 'summary'):
508        params['summary'] = settings.summary
509    if hasattr(settings, 'url'):
510        params['url'] = settings.url
511    if hasattr(settings, 'version'):
512        params['version'] = settings.version
513    if hasattr(settings, 'whiteboard'):
514        params['whiteboard'] = settings.whiteboard
515
516    if hasattr(settings, 'fixed'):
517        params['status'] = 'RESOLVED'
518        params['resolution'] = 'FIXED'
519
520    if hasattr(settings, 'invalid'):
521        params['status'] = 'RESOLVED'
522        params['resolution'] = 'INVALID'
523
524    if len(params) < 2:
525        raise BugzError('No changes were specified')
526    login(settings)
527    result = settings.call_bz(settings.bz.Bug.update, params)
528    for bug in result['bugs']:
529        changes = bug['changes']
530        if not len(changes):
531            log_info('Added comment to bug %s' % bug['id'])
532        else:
533            log_info('Modified the following fields in bug %s' % bug['id'])
534            for key in changes:
535                log_info('%-12s: removed %s' % (key, changes[key]['removed']))
536                log_info('%-12s: added %s' % (key, changes[key]['added']))
537
538
539def post(settings):
540    """Post a new bug"""
541    login(settings)
542    # load description from file if possible
543    if hasattr(settings, 'description_from'):
544        try:
545                if settings.description_from == '-':
546                    settings.description = sys.stdin.read()
547                else:
548                    settings.description = \
549                        open(settings.description_from, 'r').read()
550        except IOError as error:
551            raise BugzError('Unable to read from file: %s: %s' %
552                            (settings.description_from, error))
553
554    if not hasattr(settings, 'batch'):
555        prompt_for_bug(settings)
556
557    # raise an exception if mandatory fields are not specified.
558    if not hasattr(settings, 'product'):
559        raise RuntimeError('Product not specified')
560    if not hasattr(settings, 'component'):
561        raise RuntimeError('Component not specified')
562    if not hasattr(settings, 'summary'):
563        raise RuntimeError('Title not specified')
564    if not hasattr(settings, 'description'):
565        raise RuntimeError('Description not specified')
566
567    # append the output from append_command to the description
568    append_command = getattr(settings, 'append_command', None)
569    if append_command is not None and append_command != '':
570        append_command_output = subprocess.getoutput(append_command)
571        settings.description = settings.description + '\n\n' + \
572            '$ ' + append_command + '\n' + \
573            append_command_output
574
575    # print submission confirmation
576    print('-' * (settings.columns - 1))
577    print('%-12s: %s' % ('Product', settings.product))
578    print('%-12s: %s' % ('Component', settings.component))
579    print('%-12s: %s' % ('Title', settings.summary))
580    if hasattr(settings, 'version'):
581        print('%-12s: %s' % ('Version', settings.version))
582    print('%-12s: %s' % ('Description', settings.description))
583    if hasattr(settings, 'op_sys'):
584        print('%-12s: %s' % ('Operating System', settings.op_sys))
585    if hasattr(settings, 'platform'):
586        print('%-12s: %s' % ('Platform', settings.platform))
587    if hasattr(settings, 'priority'):
588        print('%-12s: %s' % ('Priority', settings.priority))
589    if hasattr(settings, 'severity'):
590        print('%-12s: %s' % ('Severity', settings.severity))
591    if hasattr(settings, 'alias'):
592        print('%-12s: %s' % ('Alias', settings.alias))
593    if hasattr(settings, 'assigned_to'):
594        print('%-12s: %s' % ('Assigned to', settings.assigned_to))
595    if hasattr(settings, 'cc'):
596        print('%-12s: %s' % ('CC', settings.cc))
597    if hasattr(settings, 'url'):
598        print('%-12s: %s' % ('URL', settings.url))
599    # fixme: groups
600    # fixme: status
601    # fixme: Milestone
602    print('-' * (settings.columns - 1))
603
604    if not hasattr(settings, 'batch'):
605        if settings.default_confirm in ['Y', 'y']:
606            confirm = input('Confirm bug submission (Y/n)? ')
607        else:
608            confirm = input('Confirm bug submission (y/N)? ')
609        if len(confirm) < 1:
610            confirm = settings.default_confirm
611        if confirm[0] not in ('y', 'Y'):
612            log_info('Submission aborted')
613            return
614
615    params = {}
616    params['product'] = settings.product
617    params['component'] = settings.component
618    if hasattr(settings, 'version'):
619        params['version'] = settings.version
620    params['summary'] = settings.summary
621    if hasattr(settings, 'description'):
622        params['description'] = settings.description
623    if hasattr(settings, 'op_sys'):
624        params['op_sys'] = settings.op_sys
625    if hasattr(settings, 'platform'):
626        params['platform'] = settings.platform
627    if hasattr(settings, 'priority'):
628        params['priority'] = settings.priority
629    if hasattr(settings, 'severity'):
630        params['severity'] = settings.severity
631    if hasattr(settings, 'alias'):
632        params['alias'] = settings.alias
633    if hasattr(settings, 'assigned_to'):
634        params['assigned_to'] = settings.assigned_to
635    if hasattr(settings, 'cc'):
636        params['cc'] = settings.cc
637    if hasattr(settings, 'url'):
638        params['url'] = settings.url
639
640    result = settings.call_bz(settings.bz.Bug.create, params)
641    log_info('Bug %d submitted' % result['id'])
642
643
644def search(settings):
645    """Performs a search on the bugzilla database with
646the keywords given on the title (or the body if specified).
647    """
648    valid_keys = ['alias', 'assigned_to', 'component', 'creator',
649                  'limit', 'offset', 'op_sys', 'platform',
650                  'priority', 'product', 'resolution', 'severity',
651                  'version', 'whiteboard']
652
653    params = {}
654    d = vars(settings)
655    for key in d:
656        if key in valid_keys:
657            params[key] = d[key]
658    if 'search_statuses' in d:
659        if 'all' not in d['search_statuses']:
660            params['status'] = d['search_statuses']
661    if 'terms' in d:
662        params['summary'] = d['terms']
663
664    if not params:
665        raise BugzError('Please give search terms or options.')
666
667    log_info('Searching for bugs meeting the following criteria:')
668    for key in params:
669        log_info('   {0:<20} = {1}'.format(key, params[key]))
670
671    login(settings)
672
673    result = settings.call_bz(settings.bz.Bug.search, params)['bugs']
674
675    if not len(result):
676        log_info('No bugs found.')
677    else:
678        list_bugs(result, settings)
679
680
681def connections(settings):
682    print('Known bug trackers:')
683    print()
684    for tracker in settings.connections:
685        print(tracker)
686
687
688def main():
689    ArgParser = make_arg_parser()
690    args = ArgParser.parse_args()
691
692    ConfigParser = load_config(getattr(args, 'config_file', None))
693
694    check_bugz_token()
695    settings = Settings(args, ConfigParser)
696
697    if not hasattr(args, 'func'):
698        ArgParser.print_usage()
699        return 1
700
701    try:
702        args.func(settings)
703    except BugzError as error:
704        log_error(error)
705        return 1
706    except RuntimeError as error:
707        log_error(error)
708        return 1
709    except KeyboardInterrupt:
710        log_info('Stopped due to keyboard interrupt')
711        return 1
712
713    return 0
714
715
716if __name__ == "__main__":
717    main()
718