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