├── LICENSE ├── README ├── addlabel.py ├── bugstats.py ├── csvimport.py ├── customerreport.py ├── debugmode.py ├── getcustomfields.py ├── getfieldvalues.py ├── issuedump.py ├── jiraauth.py ├── jqlsearch.py ├── migrate-target-versions.py ├── newversion.py ├── savedsearch.py └── suplinkreport.py /LICENSE: -------------------------------------------------------------------------------- 1 | # Software License Agreement (BSD License) 2 | # 3 | # Copyright (c) 2009-2011, Eucalyptus Systems, Inc. 4 | # All rights reserved. 5 | # 6 | # Redistribution and use of this software in source and binary forms, with or 7 | # without modification, are permitted provided that the following conditions 8 | # are met: 9 | # 10 | # Redistributions of source code must retain the above 11 | # copyright notice, this list of conditions and the 12 | # following disclaimer. 13 | # 14 | # Redistributions in binary form must reproduce the above 15 | # copyright notice, this list of conditions and the 16 | # following disclaimer in the documentation and/or other 17 | # materials provided with the distribution. 18 | # 19 | # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 22 | # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE 23 | # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 24 | # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 25 | # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 26 | # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 27 | # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 28 | # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 29 | # POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README: -------------------------------------------------------------------------------- 1 | This repository is a collection of Jira scripts. They require jira-python, 2 | and currently depend on bugfixes in a fork. To check out this code, run: 3 | 4 | git clone https://bitbucket.org/agrimm/jira-python.git 5 | 6 | This code requires the python requests and tlslite libraries. I have mainly 7 | tested it on Fedora 17, but upstream supports a wide variety of distros. 8 | 9 | To run any scripts in this repository, you should set your PYTHONPATH variable: 10 | 11 | export PYTHONPATH=/path/to/jira-python:/path/to/jira-scripts 12 | 13 | All scripts use jiraauth.py as a helper for authentication; that script in turn 14 | expects to find a file at ~/.jira-python/jirashell.ini , with a format like: 15 | 16 | [options] 17 | server = https://eucalyptus.atlassian.net 18 | 19 | [basic_auth] 20 | username = agrimm 21 | password = s3kr1t 22 | 23 | Note that this same config file can be used to run jirashell from jira-python's 24 | tools subdirectory. jirashell provides an interactive jira client via ipython. 25 | 26 | -------------------------------------------------------------------------------- /addlabel.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | import time 4 | import sys 5 | 6 | from jiraauth import jclient as jira 7 | 8 | ''' 9 | Synopsis: Add label(s) to issue(s) 10 | Example: addlabel.py EUCA-1234,EUCA-2345 Support,AWS 11 | 12 | issues = sys.argv[1].split(',') 13 | labels = sys.argv[2].split(',') 14 | 15 | for x in issues: 16 | issue = jira.issue(x) 17 | issue.fields.labels.extend(labels) 18 | issue.update(labels=issue.fields.labels) 19 | -------------------------------------------------------------------------------- /bugstats.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | import sys 4 | from jiraauth import jclient as jira 5 | from collections import namedtuple 6 | 7 | ''' 8 | Synopsis: This script runs a series of JQL searches to tally the number of 9 | bugs in various states. Currently there is some small overlap between a 10 | couple of the searches, so the sum total will be greater than the actual 11 | number of bugs in the system. This should be fixed. 12 | ''' 13 | 14 | 15 | QueryData = namedtuple('QueryData', ['issuetype', 'status', 'reporter', 'assignee', 'SLA', 'level', 'flagged']) 16 | querygroups = [ [ QueryData('in (Bug, Improvement)', '= "Release Pending"', None, None, None, None, None), ], 17 | [ QueryData('in (Bug, Improvement)', '= "Reopened"', None, None, None, None, None), ], 18 | [ QueryData('in (Bug, Improvement)', 'in ("Needs Clarification", "Waiting for Reporter")', None, None, None, None, None), ], 19 | [ QueryData('in (Bug, Improvement)', 'in ("In Progress", "In Review", "In QA")', None, 'is EMPTY', None, None, None), 20 | QueryData('in (Bug, Improvement)', 'in ("In Progress", "In Review", "In QA")', None, 'is not EMPTY', None, None, None), ], 21 | [ QueryData('in (Bug, Improvement)', '= Confirmed', None, 'is EMPTY', None, None, None), 22 | QueryData('in (Bug, Improvement)', '= Confirmed', None, 'in membersOf("engineering")', None, None, None), 23 | QueryData('in (Bug, Improvement)', '= Confirmed', None, 'not in membersOf("engineering")', None, None, None), ], 24 | [ QueryData('in (Bug, Improvement)', 'in (Unconfirmed, Investigating)', 'in membersOf("engineering")', 25 | 'not in membersOf("engineering")', None, None, None), 26 | QueryData('in (Bug, Improvement)', 'in (Unconfirmed, Investigating)', 'in membersOf("engineering")', 27 | 'is EMPTY', None, None, None), 28 | QueryData('in (Bug, Improvement)', 'in (Unconfirmed, Investigating)', 'in membersOf("engineering")', 29 | 'in membersOf("engineering")', None, None, None), ], 30 | [ QueryData('= Improvement', 'in (Unconfirmed, Investigating)', 'not in membersOf("engineering")', 31 | 'in membersOf("engineering")', None, None, None), 32 | QueryData('= Improvement', 'in (Unconfirmed, Investigating)', 'not in membersOf("engineering")', 33 | 'not in membersOf("engineering"); is EMPTY', None, None, None), ], 34 | [ QueryData('= Bug', 'in (Unconfirmed, Investigating)', 'not in membersOf("engineering")', 35 | 'is EMPTY', 'in ("Standard", "Premium")', None, None), 36 | QueryData('= Bug', 'in (Unconfirmed, Investigating)', 'not in membersOf("engineering")', 37 | 'in membersOf("engineering")', 'in ("Standard", "Premium")', None, None), 38 | QueryData('= Bug', 'in (Unconfirmed, Investigating)', 'not in membersOf("engineering")', 39 | 'in membersOf("tier-2-support"); in membersOf("tier-3-support")', 'in ("Standard", "Premium")', None, "is EMPTY"), 40 | QueryData('= Bug', 'in (Unconfirmed, Investigating)', 'not in membersOf("engineering")', 41 | 'not in membersOf("tier-2-support"); not in membersOf("engineering"); not in membersOf("tier-3-support")', 'in ("Standard", "Premium")', None, "is EMPTY"), 42 | QueryData('= Bug', 'in (Unconfirmed, Investigating)', 'not in membersOf("engineering")', 43 | 'not in membersOf("engineering")', 'in ("Standard", "Premium")', None, "is not EMPTY"), ], 44 | [ QueryData('= Bug', 'in (Unconfirmed, Investigating)', 'not in membersOf("engineering")', 45 | 'is EMPTY', 'is EMPTY; = "Not Applicable"', 'is EMPTY; = "Public"', None), 46 | QueryData('= Bug', 'in (Unconfirmed, Investigating)', 'not in membersOf("engineering")', 47 | 'is EMPTY', 'is EMPTY; = "Not Applicable"', 'in ("Eucalyptus Employees", "Eucalyptus and Reporter")', None), 48 | QueryData('= Bug', 'in (Unconfirmed, Investigating)', 'not in membersOf("engineering")', 49 | 'in membersOf("engineering")', 'is EMPTY; = "Not Applicable"', None, None), 50 | QueryData('= Bug', 'in (Unconfirmed, Investigating)', 'not in membersOf("engineering")', 51 | 'not in membersOf("engineering")', 'is EMPTY; = "Not Applicable"', None, None), ], 52 | ] 53 | 54 | basequery = '''project in (Euca2ools, Broker, "SAN Storage", Eucalyptus) AND 55 | issuetype in (Bug, Improvement) AND 56 | status not in (Closed, Resolved)''' 57 | 58 | print "issuetype|status|reporter|assignee|SLA|level|flagged|count|subtotal" 59 | sumTotal = 0 60 | for qgroup in querygroups: 61 | subTotal = 0 62 | data = [] 63 | for query in qgroup: 64 | fullquery = basequery 65 | for attr in ['issuetype', 'status', 'reporter', 'assignee', 'SLA', 'level', 'flagged']: 66 | criteria = getattr(query, attr) 67 | if criteria: 68 | fullquery += " AND (%s) " % " OR ".join([ "%s %s" % (attr, x) for x in criteria.split(";") ]) 69 | total, issues = jira.search_issues_with_total(fullquery) 70 | data.append("%s|%d" % ("|".join([repr(x) for x in query]), total)) 71 | subTotal += total 72 | print "\n".join(data) + "|" + str(subTotal) 73 | sumTotal += subTotal 74 | 75 | total, issues = jira.search_issues(basequery) 76 | print "GRAND TOTAL: %d" % total 77 | print "SUM TOTAL: %d" % sumTotal 78 | 79 | -------------------------------------------------------------------------------- /csvimport.py: -------------------------------------------------------------------------------- 1 | from jiraauth import jclient as jira 2 | from jira.exceptions import JIRAError 3 | import json 4 | from collections import namedtuple 5 | from StringIO import StringIO 6 | import csv 7 | import time 8 | 9 | idx=0 10 | issue_data = [] 11 | issues = [] 12 | 13 | #### Change this line to match your csv format #### 14 | IssueData = namedtuple('IssueData', ['project', 'issuetype', 'summary', 15 | 'reporter', 'dependencies', 'due_date']) 16 | 17 | # Read the file into a list of named tuples 18 | reader = csv.reader(open(sys.argv[1], "rb"), delimiter=',', quotechar='"') 19 | for line in reader: 20 | issue_data.append(IssueData(*line)) 21 | 22 | # For imports into a single project, this was useful to deal with restarting 23 | # after a failure. You simply have to know that you have a contiguous 24 | # block of issues. It would be better to write a new csv mapping rows to 25 | # issues... 26 | """ 27 | while idx < 114: 28 | issues.append(jira.issue('CFP-' + str(idx+150))) 29 | print "Fetching CFP-" + str(idx+150) 30 | idx += 1 31 | """ 32 | 33 | while idx < len(issue_data): 34 | i = issue_data[idx] 35 | 36 | #### Change this to utilize the fields you care about #### 37 | issues.append(jira.create_issue(fields={'project':{'key': i.project}, 38 | 'summary':i.summary, 39 | 'description':'', 40 | 'issuetype':{'name': i.issuetype}, 41 | }, prefetch=True)) 42 | current = issues[-1] 43 | current.update(reporter={'name':i.reporter}) 44 | # current.update(duedate=time.strftime('%Y-%m-%d', time.strptime(i.due_date, "%m/%d/%y"))) 45 | idx += 1 46 | 47 | # Iterate through the list again to make links. 48 | idx = 0 49 | while idx < len(issues): 50 | if issue_data[idx].dependencies.replace(" ", "") != "": 51 | for link_idx in issue_data[idx].dependencies.replace(" ", "").split(","): 52 | # TODO: allow deps of the form link_type:row ? For now all links are of the same type 53 | jira.create_issue_link('Blocks', issues[int(link_idx)-1].key, issues[idx].key) 54 | idx += 1 55 | 56 | print "Issues have been created!\n" + "\n".join([ 'https://eucalyptus.atlassian.net/browse/%s' % x.key for x in issues ])) 57 | 58 | -------------------------------------------------------------------------------- /customerreport.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | from jiraauth import jclient as jira 4 | 5 | ''' 6 | Synopsis: This script finds all issues with a "Customer:" snippet in a comment 7 | ''' 8 | 9 | issues = jira.search_issues('''comment ~ "Customer"''') 10 | 11 | import re 12 | for x in issues: 13 | comments = jira.comments(x.key) 14 | for comment in comments: 15 | for line in comment.body.split('\n'): 16 | if re.match('.*Customer:.*', line): 17 | print x.key + " : " + comment.author.displayName + " -- " + line 18 | break 19 | -------------------------------------------------------------------------------- /debugmode.py: -------------------------------------------------------------------------------- 1 | # "import debugmode" to debug exceptions in your code! 2 | 3 | import sys 4 | import bdb 5 | import traceback 6 | try: 7 | import epdb as debugger 8 | except ImportError: 9 | import pdb as debugger 10 | 11 | def excepthook(typ, value, tb): 12 | if typ is bdb.BdbQuit: 13 | sys.exit(1) 14 | sys.excepthook = sys.__excepthook__ 15 | 16 | debugger.post_mortem(tb, typ, value) 17 | sys.excepthook = excepthook 18 | 19 | -------------------------------------------------------------------------------- /getcustomfields.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | import sys 4 | from jiraauth import jclient as jira 5 | 6 | ''' 7 | Synopsis: Get all visible custom field ids and names, 8 | along with applicable projects and issue types 9 | ''' 10 | 11 | projectIds = [ x.id for x in jira.projects() ] 12 | issuetypeIds = [ x.id for x in jira.issue_types() ] 13 | 14 | meta = jira.createmeta(projectIds=projectIds, 15 | issuetypeIds=issuetypeIds, 16 | expand='projects.issuetypes.fields') 17 | fields = dict() 18 | for p in meta['projects']: 19 | for i in p['issuetypes']: 20 | for f in i['fields'].keys(): 21 | if not fields.has_key(f): 22 | fields[f] = (i['fields'][f]['name'], set([ p['key'] ]), set([ i['name'] ])) 23 | else: 24 | fields[f][1].add(p['key']) 25 | fields[f][2].add(i['name']) 26 | 27 | print "\n".join([ "%s: %s (%s | %s)" % (x, fields[x][0], 28 | ','.join(fields[x][1]), 29 | ','.join(fields[x][2])) for x in fields.keys() ]) 30 | -------------------------------------------------------------------------------- /getfieldvalues.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | import sys 4 | from jiraauth import jclient as jira 5 | 6 | ''' 7 | Synopsis: Get possible values for a specific field 8 | Example: getfieldvalues.py EUCA Bug Hypervisor 9 | ''' 10 | 11 | proj = sys.argv[1] 12 | issuetype = sys.argv[2] 13 | field = sys.argv[3] 14 | 15 | issuetypes = dict([ (x.name, x.id) for x in jira.issue_types() ]) 16 | meta = jira.createmeta(projectIds=[ jira.project(proj).id ], 17 | issuetypeIds=[issuetypes[issuetype]], 18 | expand='projects.issuetypes.fields') 19 | fields = meta['projects'][0]['issuetypes'][0]['fields'] 20 | target = [ fields[x] for x in fields.keys() 21 | if fields[x]['name'] == field ][0] 22 | 23 | print "\n".join([ x.get('name', x.get('value', '')) for x in target['allowedValues'] ]) 24 | -------------------------------------------------------------------------------- /issuedump.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | import time 4 | import sys 5 | 6 | from jiraauth import jclient as jira 7 | 8 | issues = sys.argv[1:] 9 | 10 | def dump_issue(issue): 11 | print "Key: " + issue.key 12 | print "Summary: " + issue.fields.summary 13 | print "Description: " + (issue.fields.description or "") 14 | print "Status: " + issue.fields.status.name 15 | print "Type: " + issue.fields.issuetype.name 16 | print "Reporter: " + issue.fields.reporter.name 17 | print "Assignee: " + getattr(issue.fields.assignee, "name", "") 18 | print "Components: " + ",".join([ x.name for x in issue.fields.components ]) 19 | if (issue.fields.issuetype.name == "Sub-task"): 20 | print "Parent: " + issue.fields.parent.key 21 | for link in issue.fields.issuelinks: 22 | if hasattr(link, 'outwardIssue'): 23 | print "Link: " + issue.key + " " + link.type.outward + " " + link.outwardIssue.key 24 | else: 25 | print "Link: " + issue.key + " " + link.type.inward + " " + link.inwardIssue.key 26 | comments = jira.comments(issue.key) 27 | for comment in comments: 28 | print "Comment by %s: %s" % (comment.author.name, comment.body) 29 | 30 | for x in issues: 31 | dump_issue(jira.issue(x)) 32 | print "---------------------------" 33 | -------------------------------------------------------------------------------- /jiraauth.py: -------------------------------------------------------------------------------- 1 | 2 | # NOTE: you must have jira-python in your PYTHONPATH for this to work 3 | from jira.client import JIRA 4 | from tools.jirashell import process_config 5 | 6 | ''' 7 | This script assumes the existence of ~/.jira-python/jirashell.ini 8 | with format like: 9 | 10 | [options] 11 | server = https://eucalyptus.atlassian.net 12 | 13 | [basic_auth] 14 | username = agrimm 15 | password = s3kr1t 16 | 17 | If you do not wish to use a config file, you can initialize a client 18 | like this: 19 | 20 | jclient = JIRA(options, basic_auth=('agrimm', 's3kr1t')) 21 | 22 | # This can also be made to work with oauth credentials 23 | # See https://jira-python.readthedocs.org/en/latest/#authentication 24 | ''' 25 | 26 | options, basic_auth, oauth = process_config() 27 | if not options.has_key('server'): 28 | options['server'] = 'https://eucalyptus.atlassian.net' 29 | 30 | jclient = None 31 | password = None 32 | if not basic_auth.has_key('username'): 33 | # Assume anonymity 34 | jclient = JIRA(options=options) 35 | else: 36 | if basic_auth.has_key('password'): 37 | password = basic_auth['password'] 38 | else: 39 | import getpass 40 | password = getpass.getpass('Eucalyptus Jira password: ') 41 | jclient = JIRA(options=options, 42 | basic_auth=(basic_auth['username'], 43 | password)) 44 | 45 | -------------------------------------------------------------------------------- /jqlsearch.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | import sys 4 | from jiraauth import jclient as jira 5 | 6 | ''' 7 | Synopsis: This script runs an arbitrary JQL search 8 | Example: jqlsearch.py 'project="SUP" and reporter="nbeard"' 9 | ''' 10 | 11 | issues = jira.search_issues(sys.argv[1]) 12 | 13 | for x in issues: 14 | print x.key + " | " + x.fields.summary 15 | -------------------------------------------------------------------------------- /migrate-target-versions.py: -------------------------------------------------------------------------------- 1 | from jiraauth import jclient 2 | import debugmode 3 | 4 | """ 5 | This script was used to copy target versions (customfield_10304) 6 | into the fix version field. It's a decent example of how to 7 | iterate over search results and update issues in the result set 8 | """ 9 | 10 | i = 0 11 | maxResults=50 12 | num = 50 13 | while i < num: 14 | num, results = jclient.search_issues_with_total('"Target Version/s" is not EMPTY and status not in (Resolved, Closed)', startAt=i) 15 | for issue in results: 16 | print "checking " + issue.key 17 | newFixVersions = None 18 | for target_version in issue.fields.customfield_10304: 19 | if not len([ x for x in issue.fields.fixVersions if x.id == target_version.id ]): 20 | if not newFixVersions: 21 | newFixVersions = [ x for x in issue.fields.fixVersions ] 22 | newFixVersions.append(target_version) 23 | if newFixVersions: 24 | print "Updating " + issue.key + " was " + \ 25 | ",".join(sorted([x.name for x in issue.fields.fixVersions ])) + \ 26 | " now " + ",".join(sorted([x.name for x in newFixVersions ])) 27 | issue.update(fixVersions=[ { 'name': x.name } for x in newFixVersions ]) 28 | i += maxResults 29 | print i 30 | 31 | -------------------------------------------------------------------------------- /newversion.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | 4 | import sys 5 | from jiraauth import jclient as jira 6 | 7 | ''' 8 | Usage example -- to create version 3.2.2 after 3.2.1 in all relevant projects: 9 | newversion.py EUCA,DOC,VNX,SAN,BROKER,INST 3.2.2 3.2.1 10 | ''' 11 | 12 | projects = sys.argv[1].split(',') 13 | new_version = sys.argv[2] 14 | after_version = None 15 | if len(sys.argv) > 2: 16 | after_version = sys.argv[3] 17 | 18 | for proj in projects: 19 | prev_ver_id = None 20 | this_ver_id = None 21 | proj_ver = jira.project_versions(proj) 22 | for v in proj_ver: 23 | if after_version and v.name == after_version: 24 | prev_ver_id = v.id 25 | elif v.name == new_version: 26 | this_ver_id = v.id 27 | if not this_ver_id: 28 | # version does not exist yet; create it 29 | this_ver_id = jira.create_version(name=new_version, project=proj).id 30 | if prev_ver_id: 31 | jira.move_version(this_ver_id, after=prev_ver_id) 32 | -------------------------------------------------------------------------------- /savedsearch.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | import sys 4 | from jiraauth import jclient as jira 5 | 6 | ''' 7 | Synopsis: ./savedsearch.py 8 | Example: ./savedsearch.py "Unowned, unconfirmed engineering issues" 9 | Example 2: ./savedsearch.py 10506 10 | ''' 11 | 12 | filterid = None 13 | try: 14 | filterid = str(int(sys.argv[1])) 15 | except ValueError: 16 | favs = jira.favourite_filters() 17 | tgt = [ x.id for x in favs if x.name == sys.argv[1] ] 18 | if len(tgt): 19 | filterid = tgt[0] 20 | 21 | if filterid: 22 | filt = jira.filter(id=filterid) 23 | issues = jira.search_issues(filt.jql) 24 | for x in issues: 25 | print x.key + " | " + x.fields.summary 26 | -------------------------------------------------------------------------------- /suplinkreport.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | ''' 4 | Synopsis: This script generates an html report of Engineering issues linked 5 | to SUP issues 6 | ''' 7 | 8 | from jiraauth import jclient as jira 9 | 10 | issues = jira.search_issues('''project = SUP''') 11 | 12 | def l(k): 13 | return '%s' % (k, k) 14 | 15 | def dump_issue(issuekey, customers, sups): 16 | issue = jira.issue(issuekey) 17 | print "" 18 | print ("".join([ l(issue.key), 19 | issue.fields.summary, 20 | issue.fields.status.name, 21 | issue.fields.issuetype.name, 22 | getattr(issue.fields.assignee, 'displayName', 'Unassigned'), 23 | ",".join([ l(k) for k in sups ]), 24 | "; ".join(customers) ]) + "").encode("utf-8") 25 | 26 | bugList = dict() 27 | supList = dict() 28 | for x in issues: 29 | if len(x.fields.issuelinks): 30 | # Get affected customers 31 | customers = [ y.value for y in x.fields.customfield_10900 or [] ] 32 | for link in x.fields.issuelinks: 33 | bugKey = None 34 | if hasattr(link, 'outwardIssue'): 35 | bugKey = link.outwardIssue.key 36 | else: 37 | bugKey = link.inwardIssue.key 38 | if bugKey.startswith('SUP'): 39 | continue 40 | if not bugList.has_key(bugKey): 41 | bugList[bugKey] = set() 42 | supList[bugKey] = set() 43 | bugList[bugKey].update(set(customers)) 44 | supList[bugKey].add(x.key) 45 | 46 | print "" 47 | print "" 48 | for key in sorted(bugList.keys()): 49 | dump_issue(key, bugList[key], supList[key]) 50 | print "
KeySummaryStatusIssue TypeAssigneeSUP issuesCustomers Affected
" 51 | --------------------------------------------------------------------------------