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