1#!/usr/bin/env python3
2#
3# bugzilla - a commandline frontend for the python bugzilla module
4#
5# Copyright (C) 2007-2017 Red Hat Inc.
6# Author: Will Woods <wwoods@redhat.com>
7# Author: Cole Robinson <crobinso@redhat.com>
8#
9# This work is licensed under the GNU GPLv2 or later.
10# See the COPYING file in the top-level directory.
11
12from __future__ import print_function
13
14import argparse
15import base64
16import datetime
17import errno
18import json
19import locale
20from logging import getLogger, DEBUG, INFO, WARN, StreamHandler, Formatter
21import os
22import re
23import socket
24import sys
25import tempfile
26
27import requests.exceptions
28
29import bugzilla
30from bugzilla._compatimports import Fault, ProtocolError, urlparse
31from bugzilla._util import to_encoding
32
33
34DEFAULT_BZ = 'https://bugs.freebsd.org/bugzilla/xmlrpc.cgi'
35
36format_field_re = re.compile("%{([a-z0-9_]+)(?::([^}]*))?}")
37
38log = getLogger(bugzilla.__name__)
39
40
41################
42# Util helpers #
43################
44
45def _is_unittest_debug():
46    return bool(os.getenv("__BUGZILLA_UNITTEST_DEBUG"))
47
48
49def open_without_clobber(name, *args):
50    """
51    Try to open the given file with the given mode; if that filename exists,
52    try "name.1", "name.2", etc. until we find an unused filename.
53    """
54    fd = None
55    count = 1
56    orig_name = name
57    while fd is None:
58        try:
59            fd = os.open(name, os.O_CREAT | os.O_EXCL, 0o666)
60        except OSError as err:
61            if err.errno == errno.EEXIST:
62                name = "%s.%i" % (orig_name, count)
63                count += 1
64            else:  # pragma: no cover
65                raise IOError(err.errno, err.strerror, err.filename)
66    fobj = open(name, *args)
67    if fd != fobj.fileno():
68        os.close(fd)
69    return fobj
70
71
72def setup_logging(debug, verbose):
73    handler = StreamHandler(sys.stderr)
74    handler.setFormatter(Formatter(
75        "[%(asctime)s] %(levelname)s (%(module)s:%(lineno)d) %(message)s",
76        "%H:%M:%S"))
77    log.addHandler(handler)
78
79    if debug:
80        log.setLevel(DEBUG)
81    elif verbose:
82        log.setLevel(INFO)
83    else:
84        log.setLevel(WARN)
85
86    if _is_unittest_debug():
87        log.setLevel(DEBUG)  # pragma: no cover
88
89
90##################
91# Option parsing #
92##################
93
94def _setup_root_parser():
95    epilog = 'Try "bugzilla COMMAND --help" for command-specific help.'
96    p = argparse.ArgumentParser(epilog=epilog)
97
98    default_url = bugzilla.Bugzilla.get_rcfile_default_url()
99    if not default_url:
100        default_url = DEFAULT_BZ
101
102    # General bugzilla connection options
103    p.add_argument('--bugzilla', default=default_url,
104            help="bugzilla URI. default: %s" % default_url)
105    p.add_argument("--nosslverify", dest="sslverify",
106                 action="store_false", default=True,
107                 help="Don't error on invalid bugzilla SSL certificate")
108    p.add_argument('--cert',
109            help="client side certificate file needed by the webserver")
110
111    p.add_argument('--login', action="store_true",
112        help='Run interactive "login" before performing the '
113             'specified command.')
114    p.add_argument('--username', help="Log in with this username")
115    p.add_argument('--password', help="Log in with this password")
116    p.add_argument('--restrict-login', action="store_true",
117                   help="The session (login token) will be restricted to "
118                        "the current IP address.")
119
120    p.add_argument('--ensure-logged-in', action="store_true",
121        help="Raise an error if we aren't logged in to bugzilla. "
122             "Consider using this if you are depending on "
123             "cached credentials, to ensure that when they expire the "
124             "tool errors, rather than subtly change output.")
125    p.add_argument('--no-cache-credentials',
126        action='store_false', default=True, dest='cache_credentials',
127        help="Don't save any bugzilla cookies or tokens to disk, and "
128             "don't use any pre-existing credentials.")
129
130    p.add_argument('--cookiefile', default=None,
131            help="cookie file to use for bugzilla authentication")
132    p.add_argument('--tokenfile', default=None,
133            help="token file to use for bugzilla authentication")
134
135    p.add_argument('--verbose', action='store_true',
136            help="give more info about what's going on")
137    p.add_argument('--debug', action='store_true',
138            help="output bunches of debugging info")
139    p.add_argument('--version', action='version',
140                   version=bugzilla.__version__)
141
142    # Allow user to specify BZClass to initialize. Kinda weird for the
143    # CLI, I'd rather people file bugs about this so we can fix our detection.
144    # So hide it from the help output but keep it for back compat
145    p.add_argument('--bztype', default='auto', help=argparse.SUPPRESS)
146
147    return p
148
149
150def _parser_add_output_options(p):
151    outg = p.add_argument_group("Output format options")
152    outg.add_argument('--full', action='store_const', dest='output',
153            const='full', default='normal',
154            help="output detailed bug info")
155    outg.add_argument('-i', '--ids', action='store_const', dest='output',
156            const='ids', help="output only bug IDs")
157    outg.add_argument('-e', '--extra', action='store_const',
158            dest='output', const='extra',
159            help="output additional bug information "
160                 "(keywords, Whiteboards, etc.)")
161    outg.add_argument('--oneline', action='store_const', dest='output',
162            const='oneline',
163            help="one line summary of the bug (useful for scripts)")
164    outg.add_argument('--json', action='store_const', dest='output',
165            const='json', help="output contents in json format")
166    outg.add_argument("--includefield", action="append",
167            help="Pass the field name to bugzilla include_fields list. "
168                 "Only the fields passed to include_fields are returned "
169                 "by the bugzilla server. "
170                 "This can be specified multiple times.")
171    outg.add_argument("--extrafield", action="append",
172            help="Pass the field name to bugzilla extra_fields list. "
173                 "When used with --json this can be used to request "
174                 "bugzilla to return values for non-default fields. "
175                 "This can be specified multiple times.")
176    outg.add_argument("--excludefield", action="append",
177            help="Pass the field name to bugzilla exclude_fields list. "
178                 "When used with --json this can be used to request "
179                 "bugzilla to not return values for a field. "
180                 "This can be specified multiple times.")
181    outg.add_argument('--raw', action='store_const', dest='output',
182            const='raw', help="raw output of the bugzilla contents. This "
183            "format is unstable and difficult to parse. Use --json instead.")
184    outg.add_argument('--outputformat',
185            help="Print output in the form given. "
186                 "You can use RPM-style tags that match bug "
187                 "fields, e.g.: '%%{id}: %%{summary}'. See the man page "
188                 "section 'Output options' for more details.")
189
190
191def _parser_add_bz_fields(rootp, command):
192    cmd_new = (command == "new")
193    cmd_query = (command == "query")
194    cmd_modify = (command == "modify")
195    if cmd_new:
196        comment_help = "Set initial bug comment/description"
197    elif cmd_query:
198        comment_help = "Search all bug comments"
199    else:
200        comment_help = "Add new bug comment"
201
202    p = rootp.add_argument_group("Standard bugzilla options")
203
204    p.add_argument('-p', '--product', help="Product name")
205    p.add_argument('-v', '--version', help="Product version")
206    p.add_argument('-c', '--component', help="Component name")
207    p.add_argument('-t', '--summary', '--short_desc', help="Bug summary")
208    p.add_argument('-l', '--comment', '--long_desc', help=comment_help)
209    if not cmd_query:
210        p.add_argument("--comment-tag", action="append",
211                help="Comment tag for the new comment")
212    p.add_argument("--sub-component", action="append",
213        help="RHBZ sub component field")
214    p.add_argument('-o', '--os', help="Operating system")
215    p.add_argument('--arch', help="Arch this bug occurs on")
216    p.add_argument('-x', '--severity', help="Bug severity")
217    p.add_argument('-z', '--priority', help="Bug priority")
218    p.add_argument('--alias', help='Bug alias (name)')
219    p.add_argument('-s', '--status', '--bug_status',
220        help='Bug status (NEW, ASSIGNED, etc.)')
221    p.add_argument('-u', '--url', help="URL field")
222    p.add_argument('-m', '--target_milestone', help="Target milestone")
223    p.add_argument('--target_release', help="RHBZ Target release")
224
225    p.add_argument('--blocked', action="append",
226        help="Bug IDs that this bug blocks")
227    p.add_argument('--dependson', action="append",
228        help="Bug IDs that this bug depends on")
229    p.add_argument('--keywords', action="append",
230        help="Bug keywords")
231    p.add_argument('--groups', action="append",
232        help="Which user groups can view this bug")
233
234    p.add_argument('--cc', action="append", help="CC list")
235    p.add_argument('-a', '--assigned_to', '--assignee', help="Bug assignee")
236    p.add_argument('-q', '--qa_contact', help='QA contact')
237
238    if not cmd_new:
239        p.add_argument('-f', '--flag', action='append',
240            help="Bug flags state. Ex:\n"
241                 "  --flag needinfo?\n"
242                 "  --flag dev_ack+ \n"
243                 "  clear with --flag needinfoX")
244        p.add_argument("--tags", action="append",
245                help="Tags/Personal Tags field.")
246
247        p.add_argument('-w', "--whiteboard", '--status_whiteboard',
248            action="append", help='Whiteboard field')
249        p.add_argument("--devel_whiteboard", action="append",
250            help='RHBZ devel whiteboard field')
251        p.add_argument("--internal_whiteboard", action="append",
252            help='RHBZ internal whiteboard field')
253        p.add_argument("--qa_whiteboard", action="append",
254            help='RHBZ QA whiteboard field')
255        p.add_argument('-F', '--fixed_in',
256            help="RHBZ 'Fixed in version' field")
257
258    # Put this at the end, so it sticks out more
259    p.add_argument('--field',
260        metavar="FIELD=VALUE", action="append", dest="fields",
261        help="Manually specify a bugzilla API field. FIELD is "
262        "the raw name used by the bugzilla instance. For example, if your "
263        "bugzilla instance has a custom field cf_my_field, do:\n"
264        "  --field cf_my_field=VALUE")
265
266    if not cmd_modify:
267        _parser_add_output_options(rootp)
268
269
270def _setup_action_new_parser(subparsers):
271    description = ("Create a new bug report. "
272        "--product, --component, --version, --summary, and --comment "
273        "must be specified. "
274        "Options that take multiple values accept comma separated lists, "
275        "including --cc, --blocks, --dependson, --groups, and --keywords.")
276    p = subparsers.add_parser("new", description=description)
277
278    _parser_add_bz_fields(p, "new")
279    g = p.add_argument_group("'new' specific options")
280    g.add_argument('--private', action='store_true', default=False,
281        help='Mark new comment as private')
282
283
284def _setup_action_query_parser(subparsers):
285    description = ("List bug reports that match the given criteria. "
286        "Certain options can accept a comma separated list to query multiple "
287        "values, including --status, --component, --product, --version, --id.")
288    epilog = ("Note: querying via explicit command line options will only "
289        "get you so far. See the --from-url option for a way to use powerful "
290        "Web UI queries from the command line.")
291    p = subparsers.add_parser("query",
292        description=description, epilog=epilog)
293
294    _parser_add_bz_fields(p, "query")
295
296    g = p.add_argument_group("'query' specific options")
297    g.add_argument('-b', '--id', '--bug_id',
298        help="specify individual bugs by IDs, separated with commas")
299    g.add_argument('-r', '--reporter',
300        help="Email: search reporter email for given address")
301    g.add_argument('--quicksearch',
302        help="Search using bugzilla's quicksearch functionality.")
303    g.add_argument('--savedsearch',
304        help="Name of a bugzilla saved search. If you don't own this "
305            "saved search, you must passed --savedsearch_sharer_id.")
306    g.add_argument('--savedsearch-sharer-id',
307        help="Owner ID of the --savedsearch. You can get this ID from "
308            "the URL bugzilla generates when running the saved search "
309            "from the web UI.")
310
311    # Keep this at the end so it sticks out more
312    g.add_argument('--from-url', metavar="WEB_QUERY_URL",
313        help="Make a working query via bugzilla's 'Advanced search' web UI, "
314             "grab the url from your browser (the string with query.cgi or "
315             "buglist.cgi in it), and --from-url will run it via the "
316             "bugzilla API. Don't forget to quote the string! "
317             "This only works for Bugzilla 5 and Red Hat bugzilla")
318
319    # Deprecated options
320    p.add_argument('-E', '--emailtype', help=argparse.SUPPRESS)
321    p.add_argument('--components_file', help=argparse.SUPPRESS)
322    p.add_argument('-U', '--url_type',
323            help=argparse.SUPPRESS)
324    p.add_argument('-K', '--keywords_type',
325            help=argparse.SUPPRESS)
326    p.add_argument('-W', '--status_whiteboard_type',
327            help=argparse.SUPPRESS)
328    p.add_argument('--fixed_in_type', help=argparse.SUPPRESS)
329
330
331def _setup_action_info_parser(subparsers):
332    description = ("List products or component information about the "
333        "bugzilla server.")
334    p = subparsers.add_parser("info", description=description)
335
336    x = p.add_mutually_exclusive_group(required=True)
337    x.add_argument('-p', '--products', action='store_true',
338            help='Get a list of products')
339    x.add_argument('-c', '--components', metavar="PRODUCT",
340            help='List the components in the given product')
341    x.add_argument('-o', '--component_owners', metavar="PRODUCT",
342            help='List components (and their owners)')
343    x.add_argument('-v', '--versions', metavar="PRODUCT",
344            help='List the versions for the given product')
345    p.add_argument('--active-components', action="store_true",
346            help='Only show active components. Combine with --components*')
347
348
349
350def _setup_action_modify_parser(subparsers):
351    usage = ("bugzilla modify [options] BUGID [BUGID...]\n"
352        "Fields that take multiple values have a special input format.\n"
353        "Append:    --cc=foo@example.com\n"
354        "Overwrite: --cc==foo@example.com\n"
355        "Remove:    --cc=-foo@example.com\n"
356        "Options that accept this format: --cc, --blocked, --dependson,\n"
357        "    --groups, --tags, whiteboard fields.")
358    p = subparsers.add_parser("modify", usage=usage)
359
360    _parser_add_bz_fields(p, "modify")
361
362    g = p.add_argument_group("'modify' specific options")
363    g.add_argument("ids", nargs="+", help="Bug IDs to modify")
364    g.add_argument('-k', '--close', metavar="RESOLUTION",
365        help='Close with the given resolution (WONTFIX, NOTABUG, etc.)')
366    g.add_argument('-d', '--dupeid', metavar="ORIGINAL",
367        help='ID of original bug. Implies --close DUPLICATE')
368    g.add_argument('--private', action='store_true', default=False,
369        help='Mark new comment as private')
370    g.add_argument('--reset-assignee', action="store_true",
371        help='Reset assignee to component default')
372    g.add_argument('--reset-qa-contact', action="store_true",
373        help='Reset QA contact to component default')
374
375
376def _setup_action_attach_parser(subparsers):
377    usage = """
378bugzilla attach --file=FILE --desc=DESC [--type=TYPE] BUGID [BUGID...]
379bugzilla attach --get=ATTACHID --getall=BUGID [--ignore-obsolete] [...]
380bugzilla attach --type=TYPE BUGID [BUGID...]"""
381    description = "Attach files or download attachments."
382    p = subparsers.add_parser("attach", description=description, usage=usage)
383
384    p.add_argument("ids", nargs="*", help="BUGID references")
385    p.add_argument('-f', '--file', metavar="FILENAME",
386            help='File to attach, or filename for data provided on stdin')
387    p.add_argument('-d', '--description', '--summary',
388            metavar="SUMMARY", dest='desc',
389            help="A short summary of the file being attached")
390    p.add_argument('-t', '--type', metavar="MIMETYPE",
391            help="Mime-type for the file being attached")
392    p.add_argument('-g', '--get', metavar="ATTACHID", action="append",
393            default=[], help="Download the attachment with the given ID")
394    p.add_argument("--getall", "--get-all", metavar="BUGID", action="append",
395            default=[], help="Download all attachments on the given bug")
396    p.add_argument('--ignore-obsolete', action="store_true",
397        help='Do not download attachments marked as obsolete.')
398    p.add_argument('-l', '--comment', '--long_desc',
399            help="Add comment with attachment")
400    p.add_argument('--private', action='store_true', default=False,
401        help='Mark new comment as private')
402
403
404def _setup_action_login_parser(subparsers):
405    usage = 'bugzilla login [--api-key] [username [password]]'
406    description = """Log into bugzilla and save a login cookie or token.
407Note: These tokens are short-lived, and future Bugzilla versions will no
408longer support token authentication at all. Please use a
409~/.config/python-bugzilla/bugzillarc file with an API key instead, or
410use 'bugzilla login --api-key' and we will save it for you."""
411    p = subparsers.add_parser("login", description=description, usage=usage)
412    p.add_argument('--api-key', action='store_true', default=False,
413                   help='Prompt for and save an API key into bugzillarc, '
414                        'rather than prompt for username and password.')
415    p.add_argument("pos_username", nargs="?", help="Optional username ",
416                   metavar="username")
417    p.add_argument("pos_password", nargs="?", help="Optional password ",
418                   metavar="password")
419
420
421def setup_parser():
422    rootparser = _setup_root_parser()
423    subparsers = rootparser.add_subparsers(dest="command")
424    subparsers.required = True
425    _setup_action_new_parser(subparsers)
426    _setup_action_query_parser(subparsers)
427    _setup_action_info_parser(subparsers)
428    _setup_action_modify_parser(subparsers)
429    _setup_action_attach_parser(subparsers)
430    _setup_action_login_parser(subparsers)
431    return rootparser
432
433
434####################
435# Command routines #
436####################
437
438def _merge_field_opts(query, opt, parser):
439    # Add any custom fields if specified
440    if opt.fields is None:
441        return
442
443    for f in opt.fields:
444        try:
445            f, v = f.split('=', 1)
446            query[f] = v
447        except Exception:
448            parser.error("Invalid field argument provided: %s" % (f))
449
450
451def _do_query(bz, opt, parser):
452    q = {}
453
454    # Parse preconstructed queries.
455    u = opt.from_url
456    if u:
457        q = bz.url_to_query(u)
458
459    if opt.components_file:
460        # Components slurped in from file (one component per line)
461        # This can be made more robust
462        clist = []
463        f = open(opt.components_file, 'r')
464        for line in f.readlines():
465            line = line.rstrip("\n")
466            clist.append(line)
467        opt.component = clist
468
469    if opt.status:
470        val = opt.status
471        stat = val
472        if val == 'ALL':
473            # leaving this out should return bugs of any status
474            stat = None
475        elif val == 'DEV':
476            # Alias for all development bug statuses
477            stat = ['NEW', 'ASSIGNED', 'NEEDINFO', 'ON_DEV',
478                'MODIFIED', 'POST', 'REOPENED']
479        elif val == 'QE':
480            # Alias for all QE relevant bug statuses
481            stat = ['ASSIGNED', 'ON_QA', 'FAILS_QA', 'PASSES_QA']
482        elif val == 'EOL':
483            # Alias for EndOfLife bug statuses
484            stat = ['VERIFIED', 'RELEASE_PENDING', 'CLOSED']
485        elif val == 'OPEN':
486            # non-Closed statuses
487            stat = ['NEW', 'ASSIGNED', 'MODIFIED', 'ON_DEV', 'ON_QA',
488                'VERIFIED', 'RELEASE_PENDING', 'POST']
489        opt.status = stat
490
491    # Convert all comma separated list parameters to actual lists,
492    # which is what bugzilla wants
493    # According to bugzilla docs, any parameter can be a list, but
494    # let's only do this for options we explicitly mention can be
495    # comma separated.
496    for optname in ["severity", "id", "status", "component",
497                    "priority", "product", "version"]:
498        val = getattr(opt, optname, None)
499        if not isinstance(val, str):
500            continue
501        setattr(opt, optname, val.split(","))
502
503    include_fields = None
504    if opt.output in ['raw', 'json']:
505        # 'raw' always does a getbug() call anyways, so just ask for ID back
506        include_fields = ['id']
507
508    elif opt.outputformat:
509        include_fields = []
510        for fieldname, rest in format_field_re.findall(opt.outputformat):
511            if fieldname == "whiteboard" and rest:
512                fieldname = rest + "_" + fieldname
513            elif fieldname == "flag":
514                fieldname = "flags"
515            elif fieldname == "cve":
516                fieldname = ["keywords", "blocks"]
517            elif fieldname == "__unicode__":
518                # Needs to be in sync with bug.__unicode__
519                fieldname = ["id", "status", "assigned_to", "summary"]
520
521            flist = isinstance(fieldname, list) and fieldname or [fieldname]
522            for f in flist:
523                if f not in include_fields:
524                    include_fields.append(f)
525
526    if include_fields is not None:
527        include_fields.sort()
528
529    built_query = bz.build_query(
530        product=opt.product or None,
531        component=opt.component or None,
532        sub_component=opt.sub_component or None,
533        version=opt.version or None,
534        reporter=opt.reporter or None,
535        bug_id=opt.id or None,
536        short_desc=opt.summary or None,
537        long_desc=opt.comment or None,
538        cc=opt.cc or None,
539        assigned_to=opt.assigned_to or None,
540        qa_contact=opt.qa_contact or None,
541        status=opt.status or None,
542        blocked=opt.blocked or None,
543        dependson=opt.dependson or None,
544        keywords=opt.keywords or None,
545        keywords_type=opt.keywords_type or None,
546        url=opt.url or None,
547        url_type=opt.url_type or None,
548        status_whiteboard=opt.whiteboard or None,
549        status_whiteboard_type=opt.status_whiteboard_type or None,
550        fixed_in=opt.fixed_in or None,
551        fixed_in_type=opt.fixed_in_type or None,
552        flag=opt.flag or None,
553        alias=opt.alias or None,
554        qa_whiteboard=opt.qa_whiteboard or None,
555        devel_whiteboard=opt.devel_whiteboard or None,
556        bug_severity=opt.severity or None,
557        priority=opt.priority or None,
558        target_release=opt.target_release or None,
559        target_milestone=opt.target_milestone or None,
560        emailtype=opt.emailtype or None,
561        include_fields=include_fields,
562        quicksearch=opt.quicksearch or None,
563        savedsearch=opt.savedsearch or None,
564        savedsearch_sharer_id=opt.savedsearch_sharer_id or None,
565        tags=opt.tags or None)
566
567    _merge_field_opts(built_query, opt, parser)
568
569    built_query.update(q)
570    q = built_query
571
572    if not q:  # pragma: no cover
573        parser.error("'query' command requires additional arguments")
574    return bz.query(q)
575
576
577def _do_info(bz, opt):
578    """
579    Handle the 'info' subcommand
580    """
581    # All these commands call getproducts internally, so do it up front
582    # with minimal include_fields for speed
583    def _filter_components(compdetails):
584        ret = {}
585        for k, v in compdetails.items():
586            if v.get("is_active", True):
587                ret[k] = v
588        return ret
589
590    productname = (opt.components or opt.component_owners or opt.versions)
591    fastcomponents = (opt.components and not opt.active_components)
592
593    include_fields = ["name", "id"]
594    if opt.components or opt.component_owners:
595        include_fields += ["components.name"]
596        if opt.component_owners:
597            include_fields += ["components.default_assigned_to"]
598        if opt.active_components:
599            include_fields += ["components.is_active"]
600
601    if opt.versions:
602        include_fields += ["versions"]
603
604    bz.refresh_products(names=productname and [productname] or None,
605            include_fields=include_fields)
606
607    if opt.products:
608        for name in sorted([p["name"] for p in bz.getproducts()]):
609            print(name)
610
611    elif fastcomponents:
612        for name in sorted(bz.getcomponents(productname)):
613            print(name)
614
615    elif opt.components:
616        details = bz.getcomponentsdetails(productname)
617        for name in sorted(_filter_components(details)):
618            print(name)
619
620    elif opt.versions:
621        proddict = bz.getproducts()[0]
622        for v in proddict['versions']:
623            print(to_encoding(v["name"]))
624
625    elif opt.component_owners:
626        details = bz.getcomponentsdetails(productname)
627        for c in sorted(_filter_components(details)):
628            print(to_encoding(u"%s: %s" % (c,
629                details[c]['default_assigned_to'])))
630
631
632def _convert_to_outputformat(output):
633    fmt = ""
634
635    if output == "normal":
636        fmt = "%{__unicode__}"
637
638    elif output == "ids":
639        fmt = "%{id}"
640
641    elif output == 'full':
642        fmt += "%{__unicode__}\n"
643        fmt += "Component: %{component}\n"
644        fmt += "CC: %{cc}\n"
645        fmt += "Blocked: %{blocks}\n"
646        fmt += "Depends: %{depends_on}\n"
647        fmt += "%{comments}\n"
648
649    elif output == 'extra':
650        fmt += "%{__unicode__}\n"
651        fmt += " +Keywords: %{keywords}\n"
652        fmt += " +QA Whiteboard: %{qa_whiteboard}\n"
653        fmt += " +Status Whiteboard: %{status_whiteboard}\n"
654        fmt += " +Devel Whiteboard: %{devel_whiteboard}\n"
655
656    elif output == 'oneline':
657        fmt += "#%{bug_id} %{status} %{assigned_to} %{component}\t"
658        fmt += "[%{target_milestone}] %{flags} %{cve}"
659
660    else:  # pragma: no cover
661        raise RuntimeError("Unknown output type '%s'" % output)
662
663    return fmt
664
665
666def _xmlrpc_converter(obj):
667    if "DateTime" in str(obj.__class__):
668        # xmlrpc DateTime object. Convert to date format that
669        # bugzilla REST API outputs
670        dobj = datetime.datetime.strptime(str(obj), '%Y%m%dT%H:%M:%S')
671        return dobj.isoformat() + "Z"
672    if "Binary" in str(obj.__class__):
673        # xmlrpc Binary object. Convert to base64
674        return base64.b64encode(obj.data).decode("utf-8")
675    raise RuntimeError(
676        "Unexpected JSON conversion class=%s" % obj.__class__)
677
678
679def _format_output_json(buglist):
680    out = {"bugs": [b.get_raw_data() for b in buglist]}
681    s = json.dumps(out, default=_xmlrpc_converter, indent=2, sort_keys=True)
682    print(s)
683
684
685def _format_output_raw(buglist):
686    for b in buglist:
687        print("Bugzilla %s: " % b.bug_id)
688        SKIP_NAMES = ["bugzilla"]
689        for attrname in sorted(b.__dict__):
690            if attrname in SKIP_NAMES:
691                continue
692            if attrname.startswith("_"):
693                continue
694            print(to_encoding(u"ATTRIBUTE[%s]: %s" %
695                              (attrname, b.__dict__[attrname])))
696        print("\n\n")
697
698
699def _bug_field_repl_cb(bz, b, matchobj):
700    # whiteboard and flag allow doing
701    #   %{whiteboard:devel} and %{flag:needinfo}
702    # That's what 'rest' matches
703    (fieldname, rest) = matchobj.groups()
704
705    if fieldname == "whiteboard" and rest:
706        fieldname = rest + "_" + fieldname
707
708    if fieldname == "flag" and rest:
709        val = b.get_flag_status(rest)
710
711    elif fieldname in ["flags", "flags_requestee"]:
712        tmpstr = []
713        for f in getattr(b, "flags", []):
714            requestee = f.get('requestee', "")
715            if fieldname == "flags":
716                requestee = ""
717            if fieldname == "flags_requestee":
718                if requestee == "":
719                    continue
720                tmpstr.append("%s" % requestee)
721            else:
722                tmpstr.append("%s%s%s" %
723                        (f['name'], f['status'], requestee))
724
725        val = ",".join(tmpstr)
726
727    elif fieldname == "cve":
728        cves = []
729        for key in getattr(b, "keywords", []):
730            # grab CVE from keywords and blockers
731            if key.find("Security") == -1:
732                continue
733            for bl in b.blocks:
734                cvebug = bz.getbug(bl)
735                for cb in cvebug.alias:
736                    if (cb.find("CVE") != -1 and
737                        cb.strip() not in cves):
738                        cves.append(cb)
739        val = ",".join(cves)
740
741    elif fieldname == "comments":
742        val = ""
743        for c in getattr(b, "comments", []):
744            val += ("\n* %s - %s:\n%s\n" % (c['time'],
745                     c.get("creator", c.get("author", "")), c['text']))
746
747    elif fieldname == "external_bugs":
748        val = ""
749        for e in getattr(b, "external_bugs", []):
750            url = e["type"]["full_url"].replace("%id%", e["ext_bz_bug_id"])
751            if not val:
752                val += "\n"
753            val += "External bug: %s\n" % url
754
755    elif fieldname == "__unicode__":
756        val = b.__unicode__()
757    else:
758        val = getattr(b, fieldname, "")
759
760    vallist = isinstance(val, list) and val or [val]
761    val = ','.join([to_encoding(v) for v in vallist])
762
763    return val
764
765
766def _format_output(bz, opt, buglist):
767    if opt.output in ['raw', 'json']:
768        include_fields = None
769        exclude_fields = None
770        extra_fields = None
771
772        if opt.includefield:
773            include_fields = opt.includefield
774        if opt.excludefield:
775            exclude_fields = opt.excludefield
776        if opt.extrafield:
777            extra_fields = opt.extrafield
778
779        buglist = bz.getbugs([b.bug_id for b in buglist],
780                include_fields=include_fields,
781                exclude_fields=exclude_fields,
782                extra_fields=extra_fields)
783        if opt.output == 'json':
784            _format_output_json(buglist)
785        if opt.output == 'raw':
786            _format_output_raw(buglist)
787        return
788
789    for b in buglist:
790        # pylint: disable=cell-var-from-loop
791        def cb(matchobj):
792            return _bug_field_repl_cb(bz, b, matchobj)
793        print(format_field_re.sub(cb, opt.outputformat))
794
795
796def _parse_triset(vallist, checkplus=True, checkminus=True, checkequal=True,
797                  splitcomma=False):
798    add_val = []
799    rm_val = []
800    set_val = None
801
802    def make_list(v):
803        if not v:
804            return []
805        if splitcomma:
806            return v.split(",")
807        return [v]
808
809    for val in isinstance(vallist, list) and vallist or [vallist]:
810        val = val or ""
811
812        if val.startswith("+") and checkplus:
813            add_val += make_list(val[1:])
814        elif val.startswith("-") and checkminus:
815            rm_val += make_list(val[1:])
816        elif val.startswith("=") and checkequal:
817            # Intentionally overwrite this
818            set_val = make_list(val[1:])
819        else:
820            add_val += make_list(val)
821
822    return add_val, rm_val, set_val
823
824
825def _do_new(bz, opt, parser):
826    # Parse options that accept comma separated list
827    def parse_multi(val):
828        return _parse_triset(val, checkplus=False, checkminus=False,
829                             checkequal=False, splitcomma=True)[0]
830
831    ret = bz.build_createbug(
832        blocks=parse_multi(opt.blocked) or None,
833        cc=parse_multi(opt.cc) or None,
834        component=opt.component or None,
835        depends_on=parse_multi(opt.dependson) or None,
836        description=opt.comment or None,
837        groups=parse_multi(opt.groups) or None,
838        keywords=parse_multi(opt.keywords) or None,
839        op_sys=opt.os or None,
840        platform=opt.arch or None,
841        priority=opt.priority or None,
842        product=opt.product or None,
843        severity=opt.severity or None,
844        summary=opt.summary or None,
845        url=opt.url or None,
846        version=opt.version or None,
847        assigned_to=opt.assigned_to or None,
848        qa_contact=opt.qa_contact or None,
849        sub_component=opt.sub_component or None,
850        alias=opt.alias or None,
851        comment_tags=opt.comment_tag or None,
852        comment_private=opt.private or None,
853    )
854
855    _merge_field_opts(ret, opt, parser)
856
857    b = bz.createbug(ret)
858    b.refresh()
859    return [b]
860
861
862def _do_modify(bz, parser, opt):
863    bugid_list = [bugid for a in opt.ids for bugid in a.split(',')]
864
865    add_wb, rm_wb, set_wb = _parse_triset(opt.whiteboard)
866    add_devwb, rm_devwb, set_devwb = _parse_triset(opt.devel_whiteboard)
867    add_intwb, rm_intwb, set_intwb = _parse_triset(opt.internal_whiteboard)
868    add_qawb, rm_qawb, set_qawb = _parse_triset(opt.qa_whiteboard)
869
870    add_blk, rm_blk, set_blk = _parse_triset(opt.blocked, splitcomma=True)
871    add_deps, rm_deps, set_deps = _parse_triset(opt.dependson, splitcomma=True)
872    add_key, rm_key, set_key = _parse_triset(opt.keywords)
873    add_cc, rm_cc, ignore = _parse_triset(opt.cc,
874                                          checkplus=False,
875                                          checkequal=False)
876    add_groups, rm_groups, ignore = _parse_triset(opt.groups,
877                                                  checkequal=False,
878                                                  splitcomma=True)
879    add_tags, rm_tags, ignore = _parse_triset(opt.tags, checkequal=False)
880
881    status = opt.status or None
882    if opt.dupeid is not None:
883        opt.close = "DUPLICATE"
884    if opt.close:
885        status = "CLOSED"
886
887    flags = []
888    if opt.flag:
889        # Convert "foo+" to tuple ("foo", "+")
890        for f in opt.flag:
891            flags.append({"name": f[:-1], "status": f[-1]})
892
893    update = bz.build_update(
894        assigned_to=opt.assigned_to or None,
895        comment=opt.comment or None,
896        comment_private=opt.private or None,
897        component=opt.component or None,
898        product=opt.product or None,
899        blocks_add=add_blk or None,
900        blocks_remove=rm_blk or None,
901        blocks_set=set_blk,
902        url=opt.url or None,
903        cc_add=add_cc or None,
904        cc_remove=rm_cc or None,
905        depends_on_add=add_deps or None,
906        depends_on_remove=rm_deps or None,
907        depends_on_set=set_deps,
908        groups_add=add_groups or None,
909        groups_remove=rm_groups or None,
910        keywords_add=add_key or None,
911        keywords_remove=rm_key or None,
912        keywords_set=set_key,
913        op_sys=opt.os or None,
914        platform=opt.arch or None,
915        priority=opt.priority or None,
916        qa_contact=opt.qa_contact or None,
917        severity=opt.severity or None,
918        status=status,
919        summary=opt.summary or None,
920        version=opt.version or None,
921        reset_assigned_to=opt.reset_assignee or None,
922        reset_qa_contact=opt.reset_qa_contact or None,
923        resolution=opt.close or None,
924        target_release=opt.target_release or None,
925        target_milestone=opt.target_milestone or None,
926        dupe_of=opt.dupeid or None,
927        fixed_in=opt.fixed_in or None,
928        whiteboard=set_wb and set_wb[0] or None,
929        devel_whiteboard=set_devwb and set_devwb[0] or None,
930        internal_whiteboard=set_intwb and set_intwb[0] or None,
931        qa_whiteboard=set_qawb and set_qawb[0] or None,
932        sub_component=opt.sub_component or None,
933        alias=opt.alias or None,
934        flags=flags or None,
935        comment_tags=opt.comment_tag or None,
936    )
937
938    # We make this a little convoluted to facilitate unit testing
939    wbmap = {
940        "whiteboard": (add_wb, rm_wb),
941        "internal_whiteboard": (add_intwb, rm_intwb),
942        "qa_whiteboard": (add_qawb, rm_qawb),
943        "devel_whiteboard": (add_devwb, rm_devwb),
944    }
945
946    for k, v in wbmap.copy().items():
947        if not v[0] and not v[1]:
948            del(wbmap[k])
949
950    _merge_field_opts(update, opt, parser)
951
952    log.debug("update bug dict=%s", update)
953    log.debug("update whiteboard dict=%s", wbmap)
954
955    if not any([update, wbmap, add_tags, rm_tags]):
956        parser.error("'modify' command requires additional arguments")
957
958    if add_tags or rm_tags:
959        ret = bz.update_tags(bugid_list,
960            tags_add=add_tags, tags_remove=rm_tags)
961        log.debug("bz.update_tags returned=%s", ret)
962    if update:
963        ret = bz.update_bugs(bugid_list, update)
964        log.debug("bz.update_bugs returned=%s", ret)
965
966    if not wbmap:
967        return
968
969    # Now for the things we can't blindly batch.
970    # Being able to prepend/append to whiteboards, which are just
971    # plain string values, is an old rhbz semantic that we try to maintain
972    # here. This is a bit weird for traditional bugzilla API
973    log.debug("Adjusting whiteboard fields one by one")
974    for bug in bz.getbugs(bugid_list):
975        update_kwargs = {}
976        for wbkey, (add_list, rm_list) in wbmap.items():
977            bugval = getattr(bug, wbkey) or ""
978            for tag in add_list:
979                if bugval:
980                    bugval += " "
981                bugval += tag
982
983            for tag in rm_list:
984                bugsplit = bugval.split()
985                for t in bugsplit[:]:
986                    if t == tag:
987                        bugsplit.remove(t)
988                bugval = " ".join(bugsplit)
989
990            update_kwargs[wbkey] = bugval
991
992        bz.update_bugs([bug.id], bz.build_update(**update_kwargs))
993
994
995def _do_get_attach(bz, opt):
996    data = {}
997
998    def _process_attachment_data(_attlist):
999        for _att in _attlist:
1000            data[_att["id"]] = _att
1001
1002    if opt.getall:
1003        for attlist in bz.get_attachments(opt.getall, None)["bugs"].values():
1004            _process_attachment_data(attlist)
1005    if opt.get:
1006        _process_attachment_data(
1007            bz.get_attachments(None, opt.get)["attachments"].values())
1008
1009    for attdata in data.values():
1010        is_obsolete = attdata.get("is_obsolete", None) == 1
1011        if opt.ignore_obsolete and is_obsolete:
1012            continue
1013
1014        att = bz.openattachment_data(attdata)
1015        outfile = open_without_clobber(att.name, "wb")
1016        data = att.read(4096)
1017        while data:
1018            outfile.write(data)
1019            data = att.read(4096)
1020        print("Wrote %s" % outfile.name)
1021
1022
1023def _do_set_attach(bz, opt, parser):
1024    if not opt.ids:
1025        parser.error("Bug ID must be specified for setting attachments")
1026
1027    if sys.stdin.isatty():
1028        if not opt.file:
1029            parser.error("--file must be specified")
1030        fileobj = open(opt.file, "rb")
1031    else:
1032        # piped input on stdin
1033        if not opt.desc:
1034            parser.error("--description must be specified if passing "
1035                         "file on stdin")
1036
1037        fileobj = tempfile.NamedTemporaryFile(prefix="bugzilla-attach.")
1038        data = sys.stdin.read(4096)
1039
1040        while data:
1041            fileobj.write(data.encode(locale.getpreferredencoding()))
1042            data = sys.stdin.read(4096)
1043        fileobj.seek(0)
1044
1045    kwargs = {}
1046    if opt.file:
1047        kwargs["filename"] = os.path.basename(opt.file)
1048    if opt.type:
1049        kwargs["contenttype"] = opt.type
1050    if opt.type in ["text/x-patch"]:
1051        kwargs["ispatch"] = True
1052    if opt.comment:
1053        kwargs["comment"] = opt.comment
1054    if opt.private:
1055        kwargs["is_private"] = True
1056    desc = opt.desc or os.path.basename(fileobj.name)
1057
1058    # Upload attachments
1059    for bugid in opt.ids:
1060        attid = bz.attachfile(bugid, fileobj, desc, **kwargs)
1061        print("Created attachment %i on bug %s" % (attid, bugid))
1062
1063
1064#################
1065# Main handling #
1066#################
1067
1068def _make_bz_instance(opt):
1069    """
1070    Build the Bugzilla instance we will use
1071    """
1072    if opt.bztype != 'auto':
1073        log.info("Explicit --bztype is no longer supported, ignoring")
1074
1075    cookiefile = None
1076    tokenfile = None
1077    use_creds = False
1078    if opt.cache_credentials:
1079        cookiefile = opt.cookiefile or -1
1080        tokenfile = opt.tokenfile or -1
1081        use_creds = True
1082
1083    return bugzilla.Bugzilla(
1084        url=opt.bugzilla,
1085        cookiefile=cookiefile,
1086        tokenfile=tokenfile,
1087        sslverify=opt.sslverify,
1088        use_creds=use_creds,
1089        cert=opt.cert)
1090
1091
1092def _handle_login(opt, action, bz):
1093    """
1094    Handle all login related bits
1095    """
1096    is_login_command = (action == 'login')
1097
1098    do_interactive_login = (is_login_command or
1099        opt.login or opt.username or opt.password)
1100    username = getattr(opt, "pos_username", None) or opt.username
1101    password = getattr(opt, "pos_password", None) or opt.password
1102    use_key = getattr(opt, "api_key", False)
1103
1104    try:
1105        if use_key:
1106            bz.interactive_save_api_key()
1107        elif do_interactive_login:
1108            if bz.api_key:
1109                print("You already have an API key configured for %s" % bz.url)
1110                print("There is no need to cache a login token. Exiting.")
1111                sys.exit(0)
1112            print("Logging into %s" % urlparse(bz.url)[1])
1113            bz.interactive_login(username, password,
1114                    restrict_login=opt.restrict_login)
1115    except bugzilla.BugzillaError as e:
1116        print(str(e))
1117        sys.exit(1)
1118
1119    if opt.ensure_logged_in and not bz.logged_in:
1120        print("--ensure-logged-in passed but you aren't logged in to %s" %
1121            bz.url)
1122        sys.exit(1)
1123
1124    if is_login_command:
1125        sys.exit(0)
1126
1127
1128def _main(unittest_bz_instance):
1129    parser = setup_parser()
1130    opt = parser.parse_args()
1131    action = opt.command
1132    setup_logging(opt.debug, opt.verbose)
1133
1134    log.debug("Launched with command line: %s", " ".join(sys.argv))
1135    log.debug("Bugzilla module: %s", bugzilla)
1136
1137    if unittest_bz_instance:
1138        bz = unittest_bz_instance
1139    else:
1140        bz = _make_bz_instance(opt)
1141
1142    # Handle login options
1143    _handle_login(opt, action, bz)
1144
1145
1146    ###########################
1147    # Run the actual commands #
1148    ###########################
1149
1150    if hasattr(opt, "outputformat"):
1151        if not opt.outputformat and opt.output not in ['raw', 'json', None]:
1152            opt.outputformat = _convert_to_outputformat(opt.output)
1153
1154    buglist = []
1155    if action == 'info':
1156        _do_info(bz, opt)
1157
1158    elif action == 'query':
1159        buglist = _do_query(bz, opt, parser)
1160
1161    elif action == 'new':
1162        buglist = _do_new(bz, opt, parser)
1163
1164    elif action == 'attach':
1165        if opt.get or opt.getall:
1166            if opt.ids:
1167                parser.error("Bug IDs '%s' not used for "
1168                    "getting attachments" % opt.ids)
1169            _do_get_attach(bz, opt)
1170        else:
1171            _do_set_attach(bz, opt, parser)
1172
1173    elif action == 'modify':
1174        _do_modify(bz, parser, opt)
1175    else:  # pragma: no cover
1176        raise RuntimeError("Unexpected action '%s'" % action)
1177
1178    # If we're doing new/query/modify, output our results
1179    if action in ['new', 'query']:
1180        _format_output(bz, opt, buglist)
1181
1182
1183def main(unittest_bz_instance=None):
1184    try:
1185        try:
1186            return _main(unittest_bz_instance)
1187        except (Exception, KeyboardInterrupt):
1188            log.debug("", exc_info=True)
1189            raise
1190    except KeyboardInterrupt:
1191        print("\nExited at user request.")
1192        sys.exit(1)
1193    except (Fault, bugzilla.BugzillaError) as e:
1194        print("\nServer error: %s" % str(e))
1195        sys.exit(3)
1196    except requests.exceptions.SSLError as e:
1197        # Give SSL recommendations
1198        print("SSL error: %s" % e)
1199        print("\nIf you trust the remote server, you can work "
1200              "around this error with:\n"
1201              "  bugzilla --nosslverify ...")
1202        sys.exit(4)
1203    except (socket.error,
1204            requests.exceptions.HTTPError,
1205            requests.exceptions.ConnectionError,
1206            requests.exceptions.InvalidURL,
1207            ProtocolError) as e:
1208        print("\nConnection lost/failed: %s" % str(e))
1209        sys.exit(2)
1210
1211
1212def cli():
1213    main()
1214