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