initial import
[vuplus_webkit] / Tools / Scripts / webkitpy / common / net / bugzilla / bugzilla.py
1 # Copyright (c) 2009 Google Inc. All rights reserved.
2 # Copyright (c) 2009 Apple Inc. All rights reserved.
3 # Copyright (c) 2010 Research In Motion Limited. All rights reserved.
4 #
5 # Redistribution and use in source and binary forms, with or without
6 # modification, are permitted provided that the following conditions are
7 # met:
8 #
9 #     * Redistributions of source code must retain the above copyright
10 # notice, this list of conditions and the following disclaimer.
11 #     * Redistributions in binary form must reproduce the above
12 # copyright notice, this list of conditions and the following disclaimer
13 # in the documentation and/or other materials provided with the
14 # distribution.
15 #     * Neither the name of Google Inc. nor the names of its
16 # contributors may be used to endorse or promote products derived from
17 # this software without specific prior written permission.
18 #
19 # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
20 # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
21 # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
22 # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
23 # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
24 # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
25 # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
26 # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
27 # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
28 # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29 # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
30 #
31 # WebKit's Python module for interacting with Bugzilla
32
33 import mimetypes
34 import os.path
35 import re
36 import StringIO
37 import urllib
38
39 from datetime import datetime # used in timestamp()
40
41 from .attachment import Attachment
42 from .bug import Bug
43
44 from webkitpy.common.system.deprecated_logging import log
45 from webkitpy.common.config import committers
46 import webkitpy.common.config.urls as config_urls
47 from webkitpy.common.net.credentials import Credentials
48 from webkitpy.common.system.user import User
49 from webkitpy.thirdparty.BeautifulSoup import BeautifulSoup, BeautifulStoneSoup, SoupStrainer
50
51
52 def timestamp():
53     return datetime.now().strftime("%Y%m%d%H%M%S")
54
55
56 # A container for all of the logic for making and parsing bugzilla queries.
57 class BugzillaQueries(object):
58
59     def __init__(self, bugzilla):
60         self._bugzilla = bugzilla
61
62     def _is_xml_bugs_form(self, form):
63         # ClientForm.HTMLForm.find_control throws if the control is not found,
64         # so we do a manual search instead:
65         return "xml" in [control.id for control in form.controls]
66
67     # This is kinda a hack.  There is probably a better way to get this information from bugzilla.
68     def _parse_result_count(self, results_page):
69         result_count_text = BeautifulSoup(results_page).find(attrs={'class': 'bz_result_count'}).string
70         result_count_parts = result_count_text.strip().split(" ")
71         if result_count_parts[0] == "Zarro":
72             return 0
73         if result_count_parts[0] == "One":
74             return 1
75         return int(result_count_parts[0])
76
77     # Note: _load_query, _fetch_bug and _fetch_bugs_from_advanced_query
78     # are the only methods which access self._bugzilla.
79
80     def _load_query(self, query):
81         self._bugzilla.authenticate()
82         full_url = "%s%s" % (config_urls.bug_server_url, query)
83         return self._bugzilla.browser.open(full_url)
84
85     def _fetch_bugs_from_advanced_query(self, query):
86         results_page = self._load_query(query)
87         if not self._parse_result_count(results_page):
88             return []
89         # Bugzilla results pages have an "XML" submit button at the bottom
90         # which can be used to get an XML page containing all of the <bug> elements.
91         # This is slighty lame that this assumes that _load_query used
92         # self._bugzilla.browser and that it's in an acceptable state.
93         self._bugzilla.browser.select_form(predicate=self._is_xml_bugs_form)
94         bugs_xml = self._bugzilla.browser.submit()
95         return self._bugzilla._parse_bugs_from_xml(bugs_xml)
96
97     def _fetch_bug(self, bug_id):
98         return self._bugzilla.fetch_bug(bug_id)
99
100     def _fetch_bug_ids_advanced_query(self, query):
101         soup = BeautifulSoup(self._load_query(query))
102         # The contents of the <a> inside the cells in the first column happen
103         # to be the bug id.
104         return [int(bug_link_cell.find("a").string)
105                 for bug_link_cell in soup('td', "first-child")]
106
107     def _parse_attachment_ids_request_query(self, page):
108         digits = re.compile("\d+")
109         attachment_href = re.compile("attachment.cgi\?id=\d+&action=review")
110         attachment_links = SoupStrainer("a", href=attachment_href)
111         return [int(digits.search(tag["href"]).group(0))
112                 for tag in BeautifulSoup(page, parseOnlyThese=attachment_links)]
113
114     def _fetch_attachment_ids_request_query(self, query):
115         return self._parse_attachment_ids_request_query(self._load_query(query))
116
117     def _parse_quips(self, page):
118         soup = BeautifulSoup(page, convertEntities=BeautifulSoup.HTML_ENTITIES)
119         quips = soup.find(text=re.compile(r"Existing quips:")).findNext("ul").findAll("li")
120         return [unicode(quip_entry.string) for quip_entry in quips]
121
122     def fetch_quips(self):
123         return self._parse_quips(self._load_query("/quips.cgi?action=show"))
124
125     # List of all r+'d bugs.
126     def fetch_bug_ids_from_pending_commit_list(self):
127         needs_commit_query_url = "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"
128         return self._fetch_bug_ids_advanced_query(needs_commit_query_url)
129
130     def fetch_bugs_matching_quicksearch(self, search_string):
131         # We may want to use a more explicit query than "quicksearch".
132         # If quicksearch changes we should probably change to use
133         # a normal buglist.cgi?query_format=advanced query.
134         quicksearch_url = "buglist.cgi?quicksearch=%s" % urllib.quote(search_string)
135         return self._fetch_bugs_from_advanced_query(quicksearch_url)
136
137     # Currently this returns all bugs across all components.
138     # In the future we may wish to extend this API to construct more restricted searches.
139     def fetch_bugs_matching_search(self, search_string, author_email=None):
140         query = "buglist.cgi?query_format=advanced"
141         if search_string:
142             query += "&short_desc_type=allwordssubstr&short_desc=%s" % urllib.quote(search_string)
143         if author_email:
144             query += "&emailreporter1=1&emailtype1=substring&email1=%s" % urllib.quote(search_string)
145         return self._fetch_bugs_from_advanced_query(query)
146
147     def fetch_patches_from_pending_commit_list(self):
148         return sum([self._fetch_bug(bug_id).reviewed_patches()
149             for bug_id in self.fetch_bug_ids_from_pending_commit_list()], [])
150
151     def fetch_bug_ids_from_commit_queue(self):
152         commit_queue_url = "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=commit-queue%2B&order=Last+Changed"
153         return self._fetch_bug_ids_advanced_query(commit_queue_url)
154
155     def fetch_patches_from_commit_queue(self):
156         # This function will only return patches which have valid committers
157         # set.  It won't reject patches with invalid committers/reviewers.
158         return sum([self._fetch_bug(bug_id).commit_queued_patches()
159                     for bug_id in self.fetch_bug_ids_from_commit_queue()], [])
160
161     def fetch_bug_ids_from_review_queue(self):
162         review_queue_url = "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?"
163         return self._fetch_bug_ids_advanced_query(review_queue_url)
164
165     # This method will make several requests to bugzilla.
166     def fetch_patches_from_review_queue(self, limit=None):
167         # [:None] returns the whole array.
168         return sum([self._fetch_bug(bug_id).unreviewed_patches()
169             for bug_id in self.fetch_bug_ids_from_review_queue()[:limit]], [])
170
171     # NOTE: This is the only client of _fetch_attachment_ids_request_query
172     # This method only makes one request to bugzilla.
173     def fetch_attachment_ids_from_review_queue(self):
174         review_queue_url = "request.cgi?action=queue&type=review&group=type"
175         return self._fetch_attachment_ids_request_query(review_queue_url)
176
177     def _login_from_row(self, row):
178         first_cell = row.find("td")
179         # The first row is just headers, we skip it.
180         if not first_cell:
181             return None
182         # When there were no results, we have a fake "<none>" entry in the table.
183         if first_cell.find(text="<none>"):
184             return None
185         # Otherwise the <td> contains a single <a> which contains the login name or a single <i> with the string "<none>".
186         return str(first_cell.find("a").string).strip()
187
188     def _parse_logins_from_editusers_results(self, results_page):
189         soup = BeautifulSoup(results_page, convertEntities=BeautifulStoneSoup.HTML_ENTITIES)
190         results_table = soup.find(id="admin_table")
191         logins = [self._login_from_row(row) for row in results_table('tr')]
192         # Filter out None from the logins.
193         return filter(lambda login: bool(login), logins)
194
195     # This only works if your account has edituser privileges.
196     # We could easily parse https://bugs.webkit.org/userprefs.cgi?tab=permissions to
197     # check permissions, but bugzilla will just return an error if we don't have them.
198     def fetch_logins_matching_substring(self, search_string):
199         review_queue_url = "editusers.cgi?action=list&matchvalue=login_name&matchstr=%s&matchtype=substr" % urllib.quote(search_string)
200         results_page = self._load_query(review_queue_url)
201         return self._parse_logins_from_editusers_results(results_page)
202
203
204 class Bugzilla(object):
205
206     def __init__(self, dryrun=False, committers=committers.CommitterList()):
207         self.dryrun = dryrun
208         self.authenticated = False
209         self.queries = BugzillaQueries(self)
210         self.committers = committers
211         self.cached_quips = []
212
213         # FIXME: We should use some sort of Browser mock object when in dryrun
214         # mode (to prevent any mistakes).
215         from webkitpy.thirdparty.autoinstalled.mechanize import Browser
216         self.browser = Browser()
217         # Ignore bugs.webkit.org/robots.txt until we fix it to allow this
218         # script.
219         self.browser.set_handle_robots(False)
220
221     def quips(self):
222         # We only fetch and parse the list of quips once per instantiation
223         # so that we do not burden bugs.webkit.org.
224         if not self.cached_quips and not self.dryrun:
225             self.cached_quips = self.queries.fetch_quips()
226         return self.cached_quips
227
228     def bug_url_for_bug_id(self, bug_id, xml=False):
229         if not bug_id:
230             return None
231         content_type = "&ctype=xml" if xml else ""
232         return "%sshow_bug.cgi?id=%s%s" % (config_urls.bug_server_url, bug_id, content_type)
233
234     def short_bug_url_for_bug_id(self, bug_id):
235         if not bug_id:
236             return None
237         return "http://webkit.org/b/%s" % bug_id
238
239     def add_attachment_url(self, bug_id):
240         return "%sattachment.cgi?action=enter&bugid=%s" % (config_urls.bug_server_url, bug_id)
241
242     def attachment_url_for_id(self, attachment_id, action="view"):
243         if not attachment_id:
244             return None
245         action_param = ""
246         if action and action != "view":
247             action_param = "&action=%s" % action
248         return "%sattachment.cgi?id=%s%s" % (config_urls.bug_server_url,
249                                              attachment_id,
250                                              action_param)
251
252     def _parse_attachment_flag(self,
253                                element,
254                                flag_name,
255                                attachment,
256                                result_key):
257         flag = element.find('flag', attrs={'name': flag_name})
258         if flag:
259             attachment[flag_name] = flag['status']
260             if flag['status'] == '+':
261                 attachment[result_key] = flag['setter']
262         # Sadly show_bug.cgi?ctype=xml does not expose the flag modification date.
263
264     def _string_contents(self, soup):
265         # WebKit's bugzilla instance uses UTF-8.
266         # BeautifulStoneSoup always returns Unicode strings, however
267         # the .string method returns a (unicode) NavigableString.
268         # NavigableString can confuse other parts of the code, so we
269         # convert from NavigableString to a real unicode() object using unicode().
270         return unicode(soup.string)
271
272     # Example: 2010-01-20 14:31 PST
273     # FIXME: Some bugzilla dates seem to have seconds in them?
274     # Python does not support timezones out of the box.
275     # Assume that bugzilla always uses PST (which is true for bugs.webkit.org)
276     _bugzilla_date_format = "%Y-%m-%d %H:%M"
277
278     @classmethod
279     def _parse_date(cls, date_string):
280         (date, time, time_zone) = date_string.split(" ")
281         # Ignore the timezone because python doesn't understand timezones out of the box.
282         date_string = "%s %s" % (date, time)
283         return datetime.strptime(date_string, cls._bugzilla_date_format)
284
285     def _date_contents(self, soup):
286         return self._parse_date(self._string_contents(soup))
287
288     def _parse_attachment_element(self, element, bug_id):
289         attachment = {}
290         attachment['bug_id'] = bug_id
291         attachment['is_obsolete'] = (element.has_key('isobsolete') and element['isobsolete'] == "1")
292         attachment['is_patch'] = (element.has_key('ispatch') and element['ispatch'] == "1")
293         attachment['id'] = int(element.find('attachid').string)
294         # FIXME: No need to parse out the url here.
295         attachment['url'] = self.attachment_url_for_id(attachment['id'])
296         attachment["attach_date"] = self._date_contents(element.find("date"))
297         attachment['name'] = self._string_contents(element.find('desc'))
298         attachment['attacher_email'] = self._string_contents(element.find('attacher'))
299         attachment['type'] = self._string_contents(element.find('type'))
300         self._parse_attachment_flag(
301                 element, 'review', attachment, 'reviewer_email')
302         self._parse_attachment_flag(
303                 element, 'commit-queue', attachment, 'committer_email')
304         return attachment
305
306     def _parse_bugs_from_xml(self, page):
307         soup = BeautifulSoup(page)
308         # Without the unicode() call, BeautifulSoup occasionally complains of being
309         # passed None for no apparent reason.
310         return [Bug(self._parse_bug_dictionary_from_xml(unicode(bug_xml)), self) for bug_xml in soup('bug')]
311
312     def _parse_bug_dictionary_from_xml(self, page):
313         soup = BeautifulStoneSoup(page, convertEntities=BeautifulStoneSoup.XML_ENTITIES)
314         bug = {}
315         bug["id"] = int(soup.find("bug_id").string)
316         bug["title"] = self._string_contents(soup.find("short_desc"))
317         bug["bug_status"] = self._string_contents(soup.find("bug_status"))
318         dup_id = soup.find("dup_id")
319         if dup_id:
320             bug["dup_id"] = self._string_contents(dup_id)
321         bug["reporter_email"] = self._string_contents(soup.find("reporter"))
322         bug["assigned_to_email"] = self._string_contents(soup.find("assigned_to"))
323         bug["cc_emails"] = [self._string_contents(element) for element in soup.findAll('cc')]
324         bug["attachments"] = [self._parse_attachment_element(element, bug["id"]) for element in soup.findAll('attachment')]
325         return bug
326
327     # Makes testing fetch_*_from_bug() possible until we have a better
328     # BugzillaNetwork abstration.
329
330     def _fetch_bug_page(self, bug_id):
331         bug_url = self.bug_url_for_bug_id(bug_id, xml=True)
332         log("Fetching: %s" % bug_url)
333         return self.browser.open(bug_url)
334
335     def fetch_bug_dictionary(self, bug_id):
336         try:
337             return self._parse_bug_dictionary_from_xml(self._fetch_bug_page(bug_id))
338         except KeyboardInterrupt:
339             raise
340         except:
341             self.authenticate()
342             return self._parse_bug_dictionary_from_xml(self._fetch_bug_page(bug_id))
343
344     # FIXME: A BugzillaCache object should provide all these fetch_ methods.
345
346     def fetch_bug(self, bug_id):
347         return Bug(self.fetch_bug_dictionary(bug_id), self)
348
349     def fetch_attachment_contents(self, attachment_id):
350         attachment_url = self.attachment_url_for_id(attachment_id)
351         # We need to authenticate to download patches from security bugs.
352         self.authenticate()
353         return self.browser.open(attachment_url).read()
354
355     def _parse_bug_id_from_attachment_page(self, page):
356         # The "Up" relation happens to point to the bug.
357         up_link = BeautifulSoup(page).find('link', rel='Up')
358         if not up_link:
359             # This attachment does not exist (or you don't have permissions to
360             # view it).
361             return None
362         match = re.search("show_bug.cgi\?id=(?P<bug_id>\d+)", up_link['href'])
363         return int(match.group('bug_id'))
364
365     def bug_id_for_attachment_id(self, attachment_id):
366         self.authenticate()
367
368         attachment_url = self.attachment_url_for_id(attachment_id, 'edit')
369         log("Fetching: %s" % attachment_url)
370         page = self.browser.open(attachment_url)
371         return self._parse_bug_id_from_attachment_page(page)
372
373     # FIXME: This should just return Attachment(id), which should be able to
374     # lazily fetch needed data.
375
376     def fetch_attachment(self, attachment_id):
377         # We could grab all the attachment details off of the attachment edit
378         # page but we already have working code to do so off of the bugs page,
379         # so re-use that.
380         bug_id = self.bug_id_for_attachment_id(attachment_id)
381         if not bug_id:
382             return None
383         attachments = self.fetch_bug(bug_id).attachments(include_obsolete=True)
384         for attachment in attachments:
385             if attachment.id() == int(attachment_id):
386                 return attachment
387         return None # This should never be hit.
388
389     def authenticate(self):
390         if self.authenticated:
391             return
392
393         if self.dryrun:
394             log("Skipping log in for dry run...")
395             self.authenticated = True
396             return
397
398         credentials = Credentials(config_urls.bug_server_host, git_prefix="bugzilla")
399
400         attempts = 0
401         while not self.authenticated:
402             attempts += 1
403             username, password = credentials.read_credentials()
404
405             log("Logging in as %s..." % username)
406             self.browser.open(config_urls.bug_server_url +
407                               "index.cgi?GoAheadAndLogIn=1")
408             self.browser.select_form(name="login")
409             self.browser['Bugzilla_login'] = username
410             self.browser['Bugzilla_password'] = password
411             response = self.browser.submit()
412
413             match = re.search("<title>(.+?)</title>", response.read())
414             # If the resulting page has a title, and it contains the word
415             # "invalid" assume it's the login failure page.
416             if match and re.search("Invalid", match.group(1), re.IGNORECASE):
417                 errorMessage = "Bugzilla login failed: %s" % match.group(1)
418                 # raise an exception only if this was the last attempt
419                 if attempts < 5:
420                     log(errorMessage)
421                 else:
422                     raise Exception(errorMessage)
423             else:
424                 self.authenticated = True
425                 self.username = username
426
427     def _commit_queue_flag(self, mark_for_landing, mark_for_commit_queue):
428         if mark_for_landing:
429             return '+'
430         elif mark_for_commit_queue:
431             return '?'
432         return 'X'
433
434     # FIXME: mark_for_commit_queue and mark_for_landing should be joined into a single commit_flag argument.
435     def _fill_attachment_form(self,
436                               description,
437                               file_object,
438                               mark_for_review=False,
439                               mark_for_commit_queue=False,
440                               mark_for_landing=False,
441                               is_patch=False,
442                               filename=None,
443                               mimetype=None):
444         self.browser['description'] = description
445         if is_patch:
446             self.browser['ispatch'] = ("1",)
447         # FIXME: Should this use self._find_select_element_for_flag?
448         self.browser['flag_type-1'] = ('?',) if mark_for_review else ('X',)
449         self.browser['flag_type-3'] = (self._commit_queue_flag(mark_for_landing, mark_for_commit_queue),)
450
451         filename = filename or "%s.patch" % timestamp()
452         if not mimetype:
453             mimetypes.add_type('text/plain', '.patch')  # Make sure mimetypes knows about .patch
454             mimetype, _ = mimetypes.guess_type(filename)
455         if not mimetype:
456             mimetype = "text/plain"  # Bugzilla might auto-guess for us and we might not need this?
457         self.browser.add_file(file_object, mimetype, filename, 'data')
458
459     def _file_object_for_upload(self, file_or_string):
460         if hasattr(file_or_string, 'read'):
461             return file_or_string
462         # Only if file_or_string is not already encoded do we want to encode it.
463         if isinstance(file_or_string, unicode):
464             file_or_string = file_or_string.encode('utf-8')
465         return StringIO.StringIO(file_or_string)
466
467     # timestamp argument is just for unittests.
468     def _filename_for_upload(self, file_object, bug_id, extension="txt", timestamp=timestamp):
469         if hasattr(file_object, "name"):
470             return file_object.name
471         return "bug-%s-%s.%s" % (bug_id, timestamp(), extension)
472
473     def add_attachment_to_bug(self,
474                               bug_id,
475                               file_or_string,
476                               description,
477                               filename=None,
478                               comment_text=None):
479         self.authenticate()
480         log('Adding attachment "%s" to %s' % (description, self.bug_url_for_bug_id(bug_id)))
481         if self.dryrun:
482             log(comment_text)
483             return
484
485         self.browser.open(self.add_attachment_url(bug_id))
486         self.browser.select_form(name="entryform")
487         file_object = self._file_object_for_upload(file_or_string)
488         filename = filename or self._filename_for_upload(file_object, bug_id)
489         self._fill_attachment_form(description, file_object, filename=filename)
490         if comment_text:
491             log(comment_text)
492             self.browser['comment'] = comment_text
493         self.browser.submit()
494
495     # FIXME: The arguments to this function should be simplified and then
496     # this should be merged into add_attachment_to_bug
497     def add_patch_to_bug(self,
498                          bug_id,
499                          file_or_string,
500                          description,
501                          comment_text=None,
502                          mark_for_review=False,
503                          mark_for_commit_queue=False,
504                          mark_for_landing=False):
505         self.authenticate()
506         log('Adding patch "%s" to %s' % (description, self.bug_url_for_bug_id(bug_id)))
507
508         if self.dryrun:
509             log(comment_text)
510             return
511
512         self.browser.open(self.add_attachment_url(bug_id))
513         self.browser.select_form(name="entryform")
514         file_object = self._file_object_for_upload(file_or_string)
515         filename = self._filename_for_upload(file_object, bug_id, extension="patch")
516         self._fill_attachment_form(description,
517                                    file_object,
518                                    mark_for_review=mark_for_review,
519                                    mark_for_commit_queue=mark_for_commit_queue,
520                                    mark_for_landing=mark_for_landing,
521                                    is_patch=True,
522                                    filename=filename)
523         if comment_text:
524             log(comment_text)
525             self.browser['comment'] = comment_text
526         self.browser.submit()
527
528     # FIXME: There has to be a more concise way to write this method.
529     def _check_create_bug_response(self, response_html):
530         match = re.search("<title>Bug (?P<bug_id>\d+) Submitted</title>",
531                           response_html)
532         if match:
533             return match.group('bug_id')
534
535         match = re.search(
536             '<div id="bugzilla-body">(?P<error_message>.+)<div id="footer">',
537             response_html,
538             re.DOTALL)
539         error_message = "FAIL"
540         if match:
541             text_lines = BeautifulSoup(
542                     match.group('error_message')).findAll(text=True)
543             error_message = "\n" + '\n'.join(
544                     ["  " + line.strip()
545                      for line in text_lines if line.strip()])
546         raise Exception("Bug not created: %s" % error_message)
547
548     def create_bug(self,
549                    bug_title,
550                    bug_description,
551                    component=None,
552                    diff=None,
553                    patch_description=None,
554                    cc=None,
555                    blocked=None,
556                    assignee=None,
557                    mark_for_review=False,
558                    mark_for_commit_queue=False):
559         self.authenticate()
560
561         log('Creating bug with title "%s"' % bug_title)
562         if self.dryrun:
563             log(bug_description)
564             # FIXME: This will make some paths fail, as they assume this returns an id.
565             return
566
567         self.browser.open(config_urls.bug_server_url + "enter_bug.cgi?product=WebKit")
568         self.browser.select_form(name="Create")
569         component_items = self.browser.find_control('component').items
570         component_names = map(lambda item: item.name, component_items)
571         if not component:
572             component = "New Bugs"
573         if component not in component_names:
574             component = User.prompt_with_list("Please pick a component:", component_names)
575         self.browser["component"] = [component]
576         if cc:
577             self.browser["cc"] = cc
578         if blocked:
579             self.browser["blocked"] = unicode(blocked)
580         if not assignee:
581             assignee = self.username
582         if assignee and not self.browser.find_control("assigned_to").disabled:
583             self.browser["assigned_to"] = assignee
584         self.browser["short_desc"] = bug_title
585         self.browser["comment"] = bug_description
586
587         if diff:
588             # _fill_attachment_form expects a file-like object
589             # Patch files are already binary, so no encoding needed.
590             assert(isinstance(diff, str))
591             patch_file_object = StringIO.StringIO(diff)
592             self._fill_attachment_form(
593                     patch_description,
594                     patch_file_object,
595                     mark_for_review=mark_for_review,
596                     mark_for_commit_queue=mark_for_commit_queue,
597                     is_patch=True)
598
599         response = self.browser.submit()
600
601         bug_id = self._check_create_bug_response(response.read())
602         log("Bug %s created." % bug_id)
603         log("%sshow_bug.cgi?id=%s" % (config_urls.bug_server_url, bug_id))
604         return bug_id
605
606     def _find_select_element_for_flag(self, flag_name):
607         # FIXME: This will break if we ever re-order attachment flags
608         if flag_name == "review":
609             return self.browser.find_control(type='select', nr=0)
610         elif flag_name == "commit-queue":
611             return self.browser.find_control(type='select', nr=1)
612         raise Exception("Don't know how to find flag named \"%s\"" % flag_name)
613
614     def clear_attachment_flags(self,
615                                attachment_id,
616                                additional_comment_text=None):
617         self.authenticate()
618
619         comment_text = "Clearing flags on attachment: %s" % attachment_id
620         if additional_comment_text:
621             comment_text += "\n\n%s" % additional_comment_text
622         log(comment_text)
623
624         if self.dryrun:
625             return
626
627         self.browser.open(self.attachment_url_for_id(attachment_id, 'edit'))
628         self.browser.select_form(nr=1)
629         self.browser.set_value(comment_text, name='comment', nr=0)
630         self._find_select_element_for_flag('review').value = ("X",)
631         self._find_select_element_for_flag('commit-queue').value = ("X",)
632         self.browser.submit()
633
634     def set_flag_on_attachment(self,
635                                attachment_id,
636                                flag_name,
637                                flag_value,
638                                comment_text=None,
639                                additional_comment_text=None):
640         # FIXME: We need a way to test this function on a live bugzilla
641         # instance.
642
643         self.authenticate()
644
645         # FIXME: additional_comment_text seems useless and should be merged into comment-text.
646         if additional_comment_text:
647             comment_text += "\n\n%s" % additional_comment_text
648         log(comment_text)
649
650         if self.dryrun:
651             return
652
653         self.browser.open(self.attachment_url_for_id(attachment_id, 'edit'))
654         self.browser.select_form(nr=1)
655
656         if comment_text:
657             self.browser.set_value(comment_text, name='comment', nr=0)
658
659         self._find_select_element_for_flag(flag_name).value = (flag_value,)
660         self.browser.submit()
661
662     # FIXME: All of these bug editing methods have a ridiculous amount of
663     # copy/paste code.
664
665     def obsolete_attachment(self, attachment_id, comment_text=None):
666         self.authenticate()
667
668         log("Obsoleting attachment: %s" % attachment_id)
669         if self.dryrun:
670             log(comment_text)
671             return
672
673         self.browser.open(self.attachment_url_for_id(attachment_id, 'edit'))
674         self.browser.select_form(nr=1)
675         self.browser.find_control('isobsolete').items[0].selected = True
676         # Also clear any review flag (to remove it from review/commit queues)
677         self._find_select_element_for_flag('review').value = ("X",)
678         self._find_select_element_for_flag('commit-queue').value = ("X",)
679         if comment_text:
680             log(comment_text)
681             # Bugzilla has two textareas named 'comment', one is somehow
682             # hidden.  We want the first.
683             self.browser.set_value(comment_text, name='comment', nr=0)
684         self.browser.submit()
685
686     def add_cc_to_bug(self, bug_id, email_address_list):
687         self.authenticate()
688
689         log("Adding %s to the CC list for bug %s" % (email_address_list, bug_id))
690         if self.dryrun:
691             return
692
693         self.browser.open(self.bug_url_for_bug_id(bug_id))
694         self.browser.select_form(name="changeform")
695         self.browser["newcc"] = ", ".join(email_address_list)
696         self.browser.submit()
697
698     def post_comment_to_bug(self, bug_id, comment_text, cc=None):
699         self.authenticate()
700
701         log("Adding comment to bug %s" % bug_id)
702         if self.dryrun:
703             log(comment_text)
704             return
705
706         self.browser.open(self.bug_url_for_bug_id(bug_id))
707         self.browser.select_form(name="changeform")
708         self.browser["comment"] = comment_text
709         if cc:
710             self.browser["newcc"] = ", ".join(cc)
711         self.browser.submit()
712
713     def close_bug_as_fixed(self, bug_id, comment_text=None):
714         self.authenticate()
715
716         log("Closing bug %s as fixed" % bug_id)
717         if self.dryrun:
718             log(comment_text)
719             return
720
721         self.browser.open(self.bug_url_for_bug_id(bug_id))
722         self.browser.select_form(name="changeform")
723         if comment_text:
724             self.browser['comment'] = comment_text
725         self.browser['bug_status'] = ['RESOLVED']
726         self.browser['resolution'] = ['FIXED']
727         self.browser.submit()
728
729     def _has_control(self, form, id):
730         return id in [control.id for control in form.controls]
731
732     def reassign_bug(self, bug_id, assignee=None, comment_text=None):
733         self.authenticate()
734
735         if not assignee:
736             assignee = self.username
737
738         log("Assigning bug %s to %s" % (bug_id, assignee))
739         if self.dryrun:
740             log(comment_text)
741             return
742
743         self.browser.open(self.bug_url_for_bug_id(bug_id))
744         self.browser.select_form(name="changeform")
745
746         if not self._has_control(self.browser, "assigned_to"):
747             log("""Failed to assign bug to you (can't find assigned_to) control.
748 Do you have EditBugs privileges at bugs.webkit.org?
749 https://bugs.webkit.org/userprefs.cgi?tab=permissions
750
751 If not, you should email webkit-committers@lists.webkit.org or ask in #webkit
752 for someone to add EditBugs to your bugs.webkit.org account.""")
753             return
754
755         if comment_text:
756             log(comment_text)
757             self.browser["comment"] = comment_text
758         self.browser["assigned_to"] = assignee
759         self.browser.submit()
760
761     def reopen_bug(self, bug_id, comment_text):
762         self.authenticate()
763
764         log("Re-opening bug %s" % bug_id)
765         # Bugzilla requires a comment when re-opening a bug, so we know it will
766         # never be None.
767         log(comment_text)
768         if self.dryrun:
769             return
770
771         self.browser.open(self.bug_url_for_bug_id(bug_id))
772         self.browser.select_form(name="changeform")
773         bug_status = self.browser.find_control("bug_status", type="select")
774         # This is a hack around the fact that ClientForm.ListControl seems to
775         # have no simpler way to ask if a control has an item named "REOPENED"
776         # without using exceptions for control flow.
777         possible_bug_statuses = map(lambda item: item.name, bug_status.items)
778         if "REOPENED" in possible_bug_statuses:
779             bug_status.value = ["REOPENED"]
780         # If the bug was never confirmed it will not have a "REOPENED"
781         # state, but only an "UNCONFIRMED" state.
782         elif "UNCONFIRMED" in possible_bug_statuses:
783             bug_status.value = ["UNCONFIRMED"]
784         else:
785             # FIXME: This logic is slightly backwards.  We won't print this
786             # message if the bug is already open with state "UNCONFIRMED".
787             log("Did not reopen bug %s, it appears to already be open with status %s." % (bug_id, bug_status.value))
788         self.browser['comment'] = comment_text
789         self.browser.submit()