#!/usr/bin/python2.6
# bugzilla - a commandline frontend for the python bugzilla module
#
# Copyright (C) 2007,2008 Red Hat Inc.
# Author: Will Woods <wwoods@redhat.com>
# 
# This program is free software; you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by the
# Free Software Foundation; either version 2 of the License, or (at your
# option) any later version.  See http://www.gnu.org/copyleft/gpl.html for
# the full text of the license.

import bugzilla, optparse
import os, sys, glob, re
import logging
import getpass
import bugzilla.util
import locale
import pprint

version = '0.5.1'
default_bz = 'https://bugzilla.redhat.com/xmlrpc.cgi'

# Initial simple logging stuff
logging.basicConfig()
log = logging.getLogger("bugzilla")
if '--debug' in sys.argv:
    log.setLevel(logging.DEBUG)
elif '--verbose' in sys.argv:
    log.setLevel(logging.INFO)
        
cmdlist = ['info','query','new','modify','login']

def to_encoding(ustring):
    if isinstance(ustring, basestring):
        if isinstance(ustring, unicode):
            return ustring.encode(locale.getpreferredencoding(), 'replace')
        return ustring
    return u''

def setup_parser():
    u =   "usage: %prog [global options] COMMAND [options]"
    u +=  "\nCommands: %s" % ', '.join(cmdlist)
    p = optparse.OptionParser(usage=u)
    p.disable_interspersed_args()
    # General bugzilla connection options
    p.add_option('--bugzilla',default=default_bz,
            help="bugzilla XMLRPC URI. default: %s" % default_bz)
    p.add_option('--bztype',default='auto',
            help="Bugzilla type. Autodetected if not set. "
                 "Available types: %s" % " ".join(bugzilla.classlist))
    p.add_option('--user',
            help="username")
    p.add_option('--password',
            help="password")
    p.add_option('--cookiefile',
            help="cookie file to use for bugzilla authentication")
    p.add_option('--verbose',action='store_true',
            help="give more info about what's going on")
    p.add_option('--debug',action='store_true',
            help="output bunches of debugging info")
    return p

def setup_action_parser(action):
    p = optparse.OptionParser(usage="usage: %%prog %s [options]" % action)
    # TODO: product and version could default to current system
    # info (read from /etc/redhat-release?)
    if action == 'new':
        p.add_option('-p','--product',
                help="REQUIRED: product name (list with 'bugzilla info -p')")
        p.add_option('-v','--version',
                help="REQUIRED: product version")
        p.add_option('-c','--component',
                help="REQUIRED: component name (list with 'bugzilla info -c PRODUCT')")
        p.add_option('-l','--comment',
                help="REQUIRED: initial bug comment")
        p.add_option('-s','--summary',dest='short_desc',
                help="REQUIRED: bug summary")
        p.add_option('-o','--os',default='Linux',dest='op_sys',
                help="OPTIONAL: operating system (default: Linux)")
        p.add_option('-a','--arch',default='All',dest='rep_platform',
                help="OPTIONAL: arch this bug occurs on (default: All)")
        p.add_option('--severity',default='medium',dest='bug_severity',
                help="OPTIONAL: bug severity (default: medium)")
        p.add_option('--priority',default='medium',dest='priority',
                help="OPTIONAL: bug priority (default: medium)")
        p.add_option('-u','--url',dest='bug_file_loc',default='http://',
                help="OPTIONAL: URL for further bug info")
        p.add_option('--cc',
                help="OPTIONAL: add emails to initial CC list")

    elif action == 'query':
        # General bug metadata
        p.add_option('-b','--bug_id',
                help="specify individual bugs by IDs, separated with commas")
        p.add_option('-p','--product',
                help="product name, comma-separated (list with 'bugzilla info -p')")
        p.add_option('-v','--version',
                help="product version")
        p.add_option('-c','--component',
                help="component name(s), comma-separated (list with 'bugzilla info -c PRODUCT')")
        p.add_option('--components_file',
                help="list of component names from a file, one component per line (list with 'bugzilla info -c PRODUCT')")
        p.add_option('-l','--long_desc',
                help="search inside bug comments")
        p.add_option('-m','--target_milestone',
                help="search for a target milestone")
        p.add_option('-s','--short_desc',
                help="search bug summaries")
        p.add_option('-t','--bug_status',default="ALL",
                help="comma-separated list of bug statuses to accept [Default:all] [Available:NEW,ASSIGNED,NEEDINFO,ON_DEV,MODIFIED,POST,ON_QA,FAILS_QA,PASSES_QA,REOPENED,VERIFIED,RELEASE_PENDING,CLOSED]")
        p.add_option('-x','--severity',
                help="search severities, comma-separated")
        p.add_option('-z','--priority',
                help="search priorities, comma-separated")

        # Email
        p.add_option('-E','--emailtype',
                help="Email: specify searching option for emails, ie. substring,notsubstring,exact,... [Default: substring]",default="substring")
        p.add_option('-o','--cc',
                help="Email: search cc lists for given address")
        p.add_option('-r','--reporter',
                help="Email: search reporter email for given address")
        p.add_option('-a','--assigned_to',
                help="Email: search for bugs assigned to this address")
        p.add_option('-q','--qa_contact',
                help="Email: search for bugs which have QA Contact assigned to this address")

        # Strings
        p.add_option('-u','--url',
                help="search keywords field for given url")
        p.add_option('-U','--url_type',
                help="specify searching option for urls, ie. anywords,allwords,nowords")
        p.add_option('-k','--keywords',
                help="search keywords field for specified words")
        p.add_option('-K','--keywords_type',
                help="specify searching option for keywords, ie. anywords,allwords,nowords")
        p.add_option('-w','--status_whiteboard',
                help="search Status Whiteboard field for specified words")
        p.add_option('-W','--status_whiteboard_type',
                help="specify searching option for Status Whiteboard, ie. anywords,allwords,nowords")

        # Boolean Charts
        p.add_option('-B','--booleantype',
                help="specify searching option for booleans, ie. substring,notsubstring,exact,... [Default: substring]",default="substring")
        p.add_option('--boolean_query',
                help="Boolean:Create your own query. Format: BooleanName-Condition-Parameter &/| ... . ie, keywords-substring-Partner & keywords-notsubstring-OtherQA")
        p.add_option('--blocked',
                help="Boolean:search for bugs that block this bug ID")
        p.add_option('--dependson',
                help="Boolean:search for bugs that depend on this bug ID")
        p.add_option('--flag',
                help="Boolean:search for bugs that have certain flag states present")
        p.add_option('--qa_whiteboard',
                help="Boolean:search for bugs that have certain QA Whiteboard text present")
        p.add_option('--devel_whiteboard',
                help="Boolean:search for bugs that have certain Devel Whiteboard text present")
        p.add_option('--alias',
                help="Boolean:search for bugs that have the provided alias")
        p.add_option('--fixed_in',
                help="search Status Whiteboard field for specified words")

        # Precomposed queries
        p.add_option('--from-url',
                help="Use the query given by a query.cgi URL. (Use quotes!)")

    elif action == 'info':
        p.add_option('-p','--products',action='store_true',
                help='Get a list of products')
        p.add_option('-c','--components',metavar="PRODUCT",
                help='List the components in the given product')
        p.add_option('-o','--component_owners',metavar="PRODUCT",
                help='List components (and their owners)')
        p.add_option('-v','--versions',metavar="PRODUCT",
                help='List the versions for the given product')
    elif action == 'modify':
        p.set_usage("usage: %prog modify [options] BUGID BUGID ...")
        p.add_option('-l','--comment',
                help='Add a comment')
        # FIXME: check value for resolution
        p.add_option('-k','--close',metavar="RESOLUTION",
                help='Close with the given resolution')
        p.add_option('-p','--private',action='store_true',default='false',
                help='Mark as private')
        p.add_option('-s','--status',
                help='Change status of bug')
        p.add_option('--assignee',
                help='Assign bugzilla to assignee')
        p.add_option('-f','--flag', action='append',
                help='Update bugzilla flags with requested type, ie fedora-cvs?\
 (Use a new option for each flag)')
        p.add_option('--cc', action='append',
                help='Add an email to the cc list')
        # TODO: --keyword, --tag, --cc, ...

    if action in ['new','query']:
        # output modifiers
        p.add_option('-f','--full',action='store_const',dest='output',
                const='full',default='normal',help="output detailed bug info")
        p.add_option('-i','--ids',action='store_const',dest='output',
                const='ids',help="output only bug IDs")
        p.add_option('-e','--extra',action='store_const',dest='output',
                const='extra',help="output additional bug information (keywords, Whiteboards, etc.)")
        p.add_option('--oneline', action='store_const', dest='output',
                const='oneline',help="one line summary of the bug (useful for scripts)")
        p.add_option('--outputformat',
                help="Print output in the form given. You can use RPM-style "+
                "tags that match bug fields, e.g.: '%{bug_id}: %{short_desc}'")
    return p

def generate_man_page():
    try:
        from logilab.common.optik_ext import ManHelpFormatter
        import datetime
    except ImportError:
        return
    today = datetime.date.today()
    datestr = today.strftime("%B %d, %Y")
    manpage = \
'''.TH bugzilla 1  "%s" "version %s" "User Commands"
.SH NAME
bugzilla \- command-line interface to Bugzilla over XML-RPC
.SH SYNOPSIS
.B bugzilla
[\\fIoptions\\fR] [\\fIcommand\\fR] [\\fIcommand-options\\fR]
.SH DESCRIPTION
.PP
.BR bugzilla
is a command-line utility that allows access to the XML-RPC interface provided
by Bugzilla.
.PP
\\fIcommand\\fP is one of:
.br
.I \\fR * login - log into the given bugzilla instance
.br
.I \\fR * new - create a new bug
.br
.I \\fR * query - search for bugs matching given criteria
.br
.I \\fR * modify - modify existing bugs
.br
.I \\fR * info - get info about the given bugzilla instance
''' % (datestr,version)
    manformatter = ManHelpFormatter()
    parser = setup_parser()
    parser.formatter = manformatter
    opt_section = parser.format_option_help()
    manpage += opt_section.replace("OPTIONS","GLOBAL OPTIONS")
    for action in cmdlist:
        action_parser = setup_action_parser(action)
        action_parser.formatter = manformatter
        opt_section = action_parser.format_option_help()
        manpage += opt_section.replace("OPTIONS",
                                       '\[oq]%s\[cq] OPTIONS' % action.upper())
    manpage += \
'''.SH EXAMPLES
.TP
bugzilla query --bug_id 62037
.SH EXIT STATUS
.BR bugzilla
returns 1 if login fails or it is interrupted, and 0 otherwise.
.SH NOTES
Not everything that's exposed in the Web UI is exposed by XML-RPC, and not
everything that's exposed by XML-RPC is used by
.BR bugzilla .
.SH BUGS
Bugs? In a sub-1.0 release? Preposterous.
.SH AUTHOR
Will Woods <wwoods@redhat.com>'''
    print manpage

def main():
    # Set up parser for global args
    parser = setup_parser()
    # Parse the commandline, woo
    (global_opt,args) = parser.parse_args()
    # Get our action from these args
    if len(args) and args[0] in cmdlist:
        action = args.pop(0)
    else:
        parser.error("command must be one of: %s" % ','.join(cmdlist))
    # Parse action-specific args
    action_parser = setup_action_parser(action)
    (opt, args) = action_parser.parse_args(args)

    # Connect to bugzilla
    log.info('Connecting to %s',global_opt.bugzilla)

    if global_opt.bztype == 'auto':
        bzclass = bugzilla.Bugzilla 
        log.info('Autodetecting Bugzilla type')
    elif global_opt.bztype in bugzilla.classlist:
        log.info('Using Bugzilla class %s' % global_opt.bztype)
        bzclass = getattr(bugzilla,global_opt.bztype)
    else:
        parser.error("bztype must be one of: %s" % str(bugzilla.classlist))
    bz=bzclass(url=global_opt.bugzilla)

    # Handle 'login' action
    if action == 'login':
        if not global_opt.user:
            sys.stdout.write('Username: ')
            user = sys.stdin.readline()
            global_opt.user = user.strip()
        if not global_opt.password:
            global_opt.password = getpass.getpass()
        sys.stdout.write('Logging in... ')
        # XXX NOTE: This will return success if you have a valid login cookie,
        # even if you give a bad username and password. WEIRD.
        if bz.login(global_opt.user,global_opt.password):
            print 'Authorization cookie received.'
            sys.exit(0)
        else:
            print 'failed.'
            sys.exit(1)

    # Set up authentication
    if global_opt.user:
        if not global_opt.password:
            global_opt.password = getpass.getpass()
        log.info('Using username/password for authentication')
        bz.login(global_opt.user,global_opt.password)
    else:
        if global_opt.cookiefile:
            bz.cookiefile = global_opt.cookiefile
        cookiefile = bz.cookiefile
        if os.path.exists(cookiefile):
            log.info('Using cookies in %s for authentication', cookiefile)
        else:
            # FIXME check to see if .bugzillarc is in use
            log.info('No authentication info provided.')
    
    # And now we actually execute the given command
    buglist = list() # save the results of query/new/modify here
    if action == 'info':
        if opt.products:
            for k in sorted(bz.products):
                pprint.pprint(k)

        if opt.components:
            for c in sorted(bz.getcomponents(opt.components)):
                pprint.pprint(c)

        if opt.component_owners:
            component_details = bz.getcomponentsdetails(opt.component_owners)
            for c in sorted(component_details):
                print "%s: %s" % (c, component_details[c]['initialowner'])

        if opt.versions:
            for p in bz.querydata['product']:
                if p['name'] == opt.versions:
                    for v in p['versions']:
                        print v

    elif action == 'query':
        # Construct the query from the list of queryable options
        q = dict()
        # Parse preconstructed queries.
        u = getattr(opt,'from_url',None)
        if u:
            q = bugzilla.util.url_to_query(u)
        # TODO: flags to save queries, list/perform saved queries
        # TODO: use the given scheme/host for the bugzilla URL
        email_count = 1
        chart_id = 0
        for a in ['product','component','components_file','version','long_desc','bug_id',
                  'short_desc','cc','assigned_to','reporter','qa_contact','bug_status',
                  'blocked','dependson','keywords','keywords_type','url','url_type','status_whiteboard',
                  'status_whiteboard_type','fixed_in','fixed_in_type','flag','alias','qa_whiteboard',
                  'devel_whiteboard','boolean_query','severity','priority','target_milestone']:
            if hasattr(opt,a):
                i = getattr(opt,a)
                if i:
                    if a == 'bug_status': # list args
                        # FIXME: statuses can differ between bugzilla instances..
                        if i == 'ALL':
                            # leaving this out should return bugs of any status
                            pass
                        elif i == 'DEV':
                            # Alias for all development bug statuses
                            q[a] = 'NEW,ASSIGNED,NEEDINFO,ON_DEV,MODIFIED,POST,REOPENED'.split(',')
                        elif i == 'QE':
                            # Alias for all QE relevant bug statuses
                            q[a] = 'ASSIGNED,ON_QA,FAILS_QA,PASSES_QA'.split(',')
                        elif i == 'EOL':
                            # Alias for EndOfLife bug statuses
                            q[a] = 'VERIFIED,RELEASE_PENDING,CLOSED'.split(',')
                        else:
                            q[a] = i.split(',')
                    elif a in ['cc','assigned_to','reporter','qa_contact']:
                        # Emails
                        # ex.: {'email1':'foo@bar.com','emailcc1':True}
                        q['email%i' % email_count] = i
                        q['email%s%i' % (a,email_count)] = True
                        q['emailtype%i' % email_count] = opt.emailtype
                        email_count += 1
                    elif a == 'components_file': 
                        # Components slurped in from file (one component per line)
                        # This can be made more robust
                        arr = []
                        f = open (i, 'r')
                        for line in f.readlines():
                            line = line.rstrip("\n")
                            arr.append(line)
                        q['component'] = ",".join(arr)
                    elif a in ['keywords','keywords_type','url','url_type','status_whiteboard',
                            'status_whiteboard_type','severity','priority']: 
                        if a == 'url':
                            q['bug_file_loc'] = i
                        elif a == 'url_type':
                            q['bug_file_loc_type'] = i
                        else:
                            q['%s' % a] = i
                    elif a in ['fixed_in','blocked','dependson','flag','qa_whiteboard','devel_whiteboard','alias']: 
                        # Boolean Charts
                        if a == 'flag':
                        # Flags have strange parameter name
                            q['field%i-0-0' % chart_id] = 'flagtypes.name'
                        elif a == 'devel_whiteboard':
                            q['field%i-0-0' % chart_id] = 'cf_devel_whiteboard'
                        else:
                            q['field%i-0-0' % chart_id] = a
                        q['value%i-0-0' % chart_id] = i
                        q['type%i-0-0' % chart_id]  = opt.booleantype
                        chart_id += 1
                    elif a == 'boolean_query': 
                        # Custom Boolean Chart query
                        # Format: BooleanName-Condition-Parameter &/| BooleanName-Condition-Parameter &/| ...
                        # ie, keywords-substring-Partner | keywords-notsubstring-PartnerVerified & keywords-notsubstring-OtherQA 
                        chart_id = 0
                        and_count = 0
                        or_count = 0
                        # Manually specified boolean query
                        x = i.split(' ')
                        for par in x :
                            if par.find('&') != -1:
                                and_count += 1
                            elif par.find('|') != -1:
                                or_count += 1
                            elif par.find('-') != -1:
                                args = par.split('-')
                                q['field%i-%i-%i' % (chart_id,and_count,or_count)] = args[0]
                                q['type%i-%i-%i' % (chart_id,and_count,or_count)]  = args[1]
                                q['value%i-%i-%i' % (chart_id,and_count,or_count)] = args[2]
                            else:
                                parser.error('Malformed boolean query: %s' % i)
                    else:
                        q[a] = i
        log.debug("bz.query: %s", q)
        buglist = bz.query(q)

    elif action == 'new':
        data = dict()
        required=['product','component','version','short_desc','comment',
             'rep_platform','bug_severity','op_sys','bug_file_loc','priority']
        optional=['cc']
        for a in optional:
            i = getattr(opt,a)
            if a == 'cc':
                data[a] = i.split(',')
            elif i:
                data[a] = i
        for a in required:
            i = getattr(opt,a)
            if i:
                data[a] = i
        for k in required:
            if k not in data:
                parser.error('Missing required argument: %s' % k)
        log.debug("bz.createbug(%s)", data)
        b = bz.createbug(**data)
        b.refresh()
        buglist = [b]
        
    elif action == 'modify':
        bugid_list = []
        for a in args:
            if ',' in a:
                for b in a.split(','):
                    bugid_list.append(b)
            else:
                bugid_list.append(a)
        # Surely there's a simpler way to do that..
        # bail out if no bugs were given
        if not bugid_list:
            parser.error('No bug IDs given (maybe you forgot an argument somewhere?)')
        # Iterate over a list of Bug objects
        # FIXME: this should totally use some multicall magic
        buglist = bz.getbugssimple(bugid_list)
        for id,bug in zip(bugid_list,buglist):
            if not bug: 
                log.error("  failed to load bug %s" % id)
                continue
            log.debug("modifying bug %s" % bug.bug_id)
            if opt.status:
                log.debug("  set status: %s" % opt.status)
                bug.setstatus(opt.status, opt.comment, private=opt.private)
            elif opt.close:
                log.debug("  close %s" % opt.close)
                bug.close(opt.close,comment=opt.comment,isprivate=opt.private)
            elif opt.assignee:
                log.debug("  assignee %s" % opt.assignee)
                bug.setassignee(assigned_to=opt.assignee,comment=opt.comment)
            elif opt.flag:
                log.debug("  flag %s" % opt.flag)
                flags=dict()
                for f in opt.flag:
                    (flagname,status) = (f[:-1],f[-1])
                    flags[flagname]=status
                bug.updateflags(flags)
            elif opt.cc:
                ccs=[]
                for c in opt.cc:
                    log.debug("  cc'ing %s" % opt.cc)
                    ccs.append(c)
                bug.addcc(ccs, comment=opt.comment)
            #this has to be last and signifies a stand-alone comment
            elif opt.comment:
                log.debug("  add comment: %s" % opt.comment)
                bug.addcomment(opt.comment, opt.private)
    else:
        print "Sorry - '%s' not implemented yet." % action

    # If we're doing new/query/modify, output our results
    if action in ['new','query']:
        if opt.outputformat:
            format_field_re = re.compile("%{[a-z0-9_]+}")
            def bug_field(matchobj):
                fieldname = matchobj.group()[2:-1]
                return str(getattr(b,fieldname))
            for b in buglist:
                print format_field_re.sub(bug_field,opt.outputformat)
        elif opt.output == 'ids':
            for b in buglist:
                print b.bug_id
        elif opt.output == 'full':
            fullbuglist = bz.getbugs([b.bug_id for b in buglist])
            for b in fullbuglist:
                print to_encoding(u'#%s %s - %s - %s' %
                        (b.bug_id, b.bug_status, b.assigned_to, b.short_desc))
                if b.cc: print "CC: %s" % " ".join(b.cc)
                if b.blocked: print "Blocked: %s" % " ".join([str(i) for i in b.blocked])
                if b.dependson: print "Depends: %s" % " ".join([str(i) for i in b.dependson])
                for c in b.longdescs:
                    if 'email' in c:
                        email = c['email']
                    elif 'safe_email' in c:
                        email = c['safe_email']
                    else:
                        email = 'Unknown'
                    print to_encoding(u"* %s - %s (%s):\n%s\n" % (c['time'],
                        c['author'], email, c['body']))
        elif opt.output == 'normal':
            for b in buglist:
                print to_encoding(u'#%s %s - %s - %s' %
                        (b.bug_id, b.bug_status, b.assigned_to, b.short_desc))

        elif opt.output == 'extra':
            print "Grabbing 'extra' bug information. This could take a moment."
            fullbuglist = bz.getbugs([b.bug_id for b in buglist])
            for b in fullbuglist:
                print to_encoding(u'#%s %s - %s - %s' %
                        (b.bug_id, b.bug_status, b.assigned_to, b.short_desc))
                if hasattr(b, 'keywords') and b.keywords:
                    print to_encoding(u" +Keywords: %s" % b.keywords)
                if hasattr(b, 'qa_whiteboard') and b.qa_whiteboard:
                    print to_encoding(u" +QA Whiteboard: %s" % b.qa_whiteboard)
                if hasattr(b, 'status_whiteboard') and b.status_whiteboard:
                    print to_encoding(u" +Status Whiteboard: %s" %
                            b.status_whiteboard)
                if hasattr(b, 'devel_whiteboard') and b.devel_whiteboard:
                    print to_encoding(u" +Devel Whiteboard: %s" %
                            b.devel_whiteboard)
            print "\nBugs listed: ",len(buglist)
        elif opt.output == 'oneline':
            fullbuglist = bz.getbugs([b.bug_id for b in buglist])
            for b in fullbuglist:
                flags=''
                cve=''
                #grab all the flags that are set
                for i in b.flag_types:
                    for s in i['flags']:
                        if s['status']: flags += i['name'] + str(s['status']) + " "
                #grab CVEs by searching the keywords and grabbing that bugzilla
                if b.keywords.find("Security") != -1:
                    for bl in b.blocked:
                        cvebug = bz.getbug(bl)
                        for cb in cvebug.alias:
                            if cb.find("CVE") != -1:
                                cve += cb + " "
                print to_encoding(u"#%s %8s %22s %s\t[%s] %s %s" %
                        (b.bug_id, b.bug_status, b.assigned_to, b.component,
                            b.target_milestone, flags, cve))
        else:
            parser.error("opt.output was set to something weird.")

if __name__ == '__main__':
    try:
        if '--generate-man' in sys.argv:
            generate_man_page()
        else:
            main()
    except KeyboardInterrupt:
        print "\ninterrupted."
        sys.exit(1)
