2009-06-25 Eric Seidel <eric@webkit.org>
[WebKit-https.git] / WebKitTools / Scripts / modules / bugzilla.py
1 # Copyright (c) 2009, Google Inc. All rights reserved.
2
3 # Redistribution and use in source and binary forms, with or without
4 # modification, are permitted provided that the following conditions are
5 # met:
6
7 #     * Redistributions of source code must retain the above copyright
8 # notice, this list of conditions and the following disclaimer.
9 #     * Redistributions in binary form must reproduce the above
10 # copyright notice, this list of conditions and the following disclaimer
11 # in the documentation and/or other materials provided with the
12 # distribution.
13 #     * Neither the name of Google Inc. nor the names of its
14 # contributors may be used to endorse or promote products derived from
15 # this software without specific prior written permission.
16
17 # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
18 # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
19 # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
20 # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
21 # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
22 # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
23 # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
24 # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
25 # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
26 # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
27 # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
28 #
29 # WebKit's Python module for interacting with Bugzilla
30
31 import getpass
32 import subprocess
33 import sys
34 import urllib2
35
36 try:
37     from BeautifulSoup import BeautifulSoup
38     from mechanize import Browser
39 except ImportError, e:
40     print """
41 BeautifulSoup and mechanize are required.
42
43 To install:
44 sudo easy_install BeautifulSoup mechanize
45
46 Or from the web:
47 http://www.crummy.com/software/BeautifulSoup/
48 http://wwwsearch.sourceforge.net/mechanize/
49 """
50     exit(1)
51
52 def log(string):
53     print >> sys.stderr, string
54
55 # FIXME: This should not depend on git for config storage
56 def read_config(key):
57     # Need a way to read from svn too
58     config_process = subprocess.Popen("git config --get bugzilla." + key, stdout=subprocess.PIPE, shell=True)
59     value = config_process.communicate()[0]
60     return_code = config_process.wait()
61
62     if return_code:
63         return None
64     return value.rstrip('\n')
65
66 class Bugzilla:
67     def __init__(self, dryrun=False):
68         self.dryrun = dryrun
69         self.authenticated = False
70         
71         # Defaults (until we support better option parsing):
72         self.bug_server = "https://bugs.webkit.org/"
73         
74         self.browser = Browser()
75         # Ignore bugs.webkit.org/robots.txt until we fix it to allow this script
76         self.browser.set_handle_robots(False)
77
78     # This could eventually be a text file
79     reviewer_usernames_to_full_names = {
80         "abarth" : "Adam Barth",
81         "adele" : "Adele Peterson",
82         "aroben" : "Adam Roben",
83         "ap" : "Alexey Proskuryakov",
84         "ariya.hidayat" : "Ariya Hidayat",
85         "beidson" : "Brady Eidson",
86         "darin" : "Darin Adler",
87         "ddkilzer" : "David Kilzer",
88         "dglazkov" : "Dimitri Glazkov",
89         "eric" : "Eric Seidel",
90         "fishd" : "Darin Fisher",
91         "gns" : "Gustavo Noronha",
92         "hyatt" : "David Hyatt",
93         "jmalonzo" : "Jan Alonzo",
94         "justin.garcia" : "Justin Garcia",
95         "kevino" : "Kevin Ollivier",
96         "levin" : "David Levin",
97         "mitz" : "Dan Bernstein",
98         "mjs" : "Maciej Stachowiak",
99         "mrowe" : "Mark Rowe",
100         "oliver" : "Oliver Hunt",
101         "sam" : "Sam Weinig",
102         "staikos" : "George Staikos",
103         "timothy" : "Timothy Hatcher",
104         "treat" : "Adam Treat",
105         "vestbo" : u'Tor Arne Vestb\xc3',
106         "xan.lopez" : "Xan Lopez",
107         "zecke" : "Holger Freyther",
108         "zimmermann" : "Nikolas Zimmermann",
109     }
110
111     def full_name_from_bugzilla_name(self, bugzilla_name):
112         if not bugzilla_name in self.reviewer_usernames_to_full_names:
113             raise Exception("ERROR: Unknown reviewer! " + bugzilla_name)
114         return self.reviewer_usernames_to_full_names[bugzilla_name]
115
116     def bug_url_for_bug_id(self, bug_id):
117         bug_base_url = self.bug_server + "show_bug.cgi?id="
118         return "%s%s" % (bug_base_url, bug_id)
119     
120     def attachment_url_for_id(self, attachment_id, action="view"):
121         attachment_base_url = self.bug_server + "attachment.cgi?id="
122         return "%s%s&action=%s" % (attachment_base_url, attachment_id, action)
123
124     def fetch_attachments_from_bug(self, bug_id):
125         bug_url = self.bug_url_for_bug_id(bug_id)
126         log("Fetching: " + bug_url)
127
128         page = urllib2.urlopen(bug_url)
129         soup = BeautifulSoup(page)
130     
131         attachment_table = soup.find('table', {'cellspacing':"0", 'cellpadding':"4", 'border':"1"})
132     
133         attachments = []
134         # Grab a list of non-obsoleted patch files 
135         for attachment_row in attachment_table.findAll('tr'):
136             first_cell = attachment_row.find('td')
137             if not first_cell:
138                 continue # This is the header, no cells
139             if first_cell.has_key('colspan'):
140                 break # this is the last row
141             
142             attachment = {}
143             attachment['obsolete'] = (attachment_row.has_key('class') and attachment_row['class'] == "bz_obsolete")
144             
145             cells = attachment_row.findAll('td')
146             attachment_link = cells[0].find('a')
147             attachment['url'] = self.bug_server + attachment_link['href'] # urls are relative
148             attachment['id'] = attachment['url'].split('=')[1] # e.g. https://bugs.webkit.org/attachment.cgi?id=31223
149             attachment['name'] = attachment_link.string
150             # attachment['type'] = cells[1]
151             # attachment['date'] = cells[2]
152             # attachment['size'] = cells[3]
153             review_status = cells[4]
154             # action_links = cells[5]
155
156             if str(review_status).find("review+") != -1:
157                 reviewer = review_status.contents[0].split(':')[0] # name:\n review+\n
158                 reviewer_full_name = self.full_name_from_bugzilla_name(reviewer)
159                 attachment['reviewer'] = reviewer_full_name
160
161             attachments.append(attachment)
162         return attachments
163
164     def fetch_reviewed_patches_from_bug(self, bug_id):
165         reviewed_patches = []
166         for attachment in self.fetch_attachments_from_bug(bug_id):
167             if 'reviewer' in attachment and not attachment['obsolete']:
168                 reviewed_patches.append(attachment)
169         return reviewed_patches
170
171     def fetch_bug_ids_from_commit_queue(self):
172         # FIXME: We should have an option for restricting the search by email.  Example:
173         # unassigned_only = "&emailassigned_to1=1&emailtype1=substring&email1=unassigned"
174         commit_queue_url = "https://bugs.webkit.org/buglist.cgi?query_format=advanced&bug_status=UNCONFIRMED&bug_status=NEW&bug_status=ASSIGNED&bug_status=REOPENED&field0-0-0=flagtypes.name&type0-0-0=equals&value0-0-0=review%2B"
175         log("Loading commit queue")
176
177         page = urllib2.urlopen(commit_queue_url)
178         soup = BeautifulSoup(page)
179     
180         bug_ids = []
181         # Grab the cells in the first column (which happens to be the bug ids)
182         for bug_link_cell in soup('td', "first-child"): # tds with the class "first-child"
183             bug_link = bug_link_cell.find("a")
184             bug_ids.append(bug_link.string) # the contents happen to be the bug id
185     
186         return bug_ids
187
188     def fetch_patches_from_commit_queue(self):
189         patches_to_land = []
190         for bug_id in self.fetch_bug_ids_from_commit_queue():
191             patches = self.fetch_reviewed_patches_from_bug(bug_id)
192             patches_to_land += patches
193         return patches_to_land
194
195     def authenticate(self, username=None, password=None):
196         if self.authenticated:
197             return
198         
199         if not username:
200             username = read_config("username")
201             if not username:
202                 username = raw_input("Bugzilla login: ")
203         if not password:
204             password = read_config("password")
205             if not password:
206                 password = getpass.getpass("Bugzilla password for %s: " % username)
207
208         log("Logging in as %s..." % username)
209         if self.dryrun:
210             self.authenticated = True
211             return
212         self.browser.open(self.bug_server + "/index.cgi?GoAheadAndLogIn=1")
213         self.browser.select_form(name="login")
214         self.browser['Bugzilla_login'] = username
215         self.browser['Bugzilla_password'] = password
216         self.browser.submit()
217
218         # We really should check the result codes and try again as necessary
219         self.authenticated = True
220
221     def add_patch_to_bug(self, bug_id, patch_file_object, description, comment_text=None, mark_for_review=False):
222         self.authenticate()
223         
224         log('Adding patch "%s" to bug %s' % (description, bug_id))
225         if self.dryrun:
226             log(comment_text)
227             return
228         
229         self.browser.open(self.bug_server + "/attachment.cgi?action=enter&bugid=" + bug_id)
230         self.browser.select_form(name="entryform")
231         self.browser['description'] = description
232         self.browser['ispatch'] = ("1",)
233         if comment_text:
234             log(comment_text)
235             self.browser['comment'] = comment_text
236         self.browser['flag_type-1'] = ('?',) if mark_for_review else ('X',)
237         self.browser.add_file(patch_file_object, "text/plain", "bugzilla_requires_a_filename.patch")
238         self.browser.submit()
239
240     def obsolete_attachment(self, attachment_id, comment_text = None):
241         self.authenticate()
242
243         log("Obsoleting attachment: %s" % attachment_id)
244         if self.dryrun:
245             log(comment_text)
246             return
247
248         self.browser.open(self.attachment_url_for_id(attachment_id, 'edit'))
249         self.browser.select_form(nr=0)
250         self.browser.find_control('isobsolete').items[0].selected = True
251         # Also clear any review flag (to remove it from review/commit queues)
252         self.browser.find_control(type='select', nr=0).value = ("X",)
253         if comment_text:
254             log(comment_text)
255             self.browser['comment'] = comment_text
256         self.browser.submit()
257     
258     def post_comment_to_bug(self, bug_id, comment_text):
259         self.authenticate()
260
261         log("Adding comment to bug %s" % bug_id)
262         if self.dryrun:
263             log(comment_text)
264             return
265
266         self.browser.open(self.bug_url_for_bug_id(bug_id))
267         self.browser.select_form(name="changeform")
268         self.browser['comment'] = comment_text
269         self.browser.submit()
270
271     def close_bug_as_fixed(self, bug_id, comment_text=None):
272         self.authenticate()
273
274         log("Closing bug %s as fixed" % bug_id)
275         if self.dryrun:
276             log(comment_text)
277             return
278
279         self.browser.open(self.bug_url_for_bug_id(bug_id))
280         self.browser.select_form(name="changeform")
281         if comment_text:
282             log(comment_text)
283             self.browser['comment'] = comment_text
284         self.browser['knob'] = ['resolve']
285         self.browser['resolution'] = ['FIXED']
286         self.browser.submit()