• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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 re
34import subprocess
35
36from datetime import datetime # used in timestamp()
37
38# Import WebKit-specific modules.
39from webkitpy.webkit_logging import error, log
40from webkitpy.committers import CommitterList
41from webkitpy.credentials import Credentials
42from webkitpy.user import User
43
44# WebKit includes a built copy of BeautifulSoup in Scripts/webkitpy
45# so this import should always succeed.
46from .BeautifulSoup import BeautifulSoup, SoupStrainer
47
48from mechanize import Browser
49
50
51def parse_bug_id(message):
52    match = re.search("http\://webkit\.org/b/(?P<bug_id>\d+)", message)
53    if match:
54        return int(match.group('bug_id'))
55    match = re.search(
56        Bugzilla.bug_server_regex + "show_bug\.cgi\?id=(?P<bug_id>\d+)",
57        message)
58    if match:
59        return int(match.group('bug_id'))
60    return None
61
62
63def timestamp():
64    return datetime.now().strftime("%Y%m%d%H%M%S")
65
66
67class Attachment(object):
68
69    def __init__(self, attachment_dictionary, bug):
70        self._attachment_dictionary = attachment_dictionary
71        self._bug = bug
72        self._reviewer = None
73        self._committer = None
74
75    def _bugzilla(self):
76        return self._bug._bugzilla
77
78    def id(self):
79        return int(self._attachment_dictionary.get("id"))
80
81    def attacher_is_committer(self):
82        return self._bugzilla.committers.committer_by_email(
83            patch.attacher_email())
84
85    def attacher_email(self):
86        return self._attachment_dictionary.get("attacher_email")
87
88    def bug(self):
89        return self._bug
90
91    def bug_id(self):
92        return int(self._attachment_dictionary.get("bug_id"))
93
94    def is_patch(self):
95        return not not self._attachment_dictionary.get("is_patch")
96
97    def is_obsolete(self):
98        return not not self._attachment_dictionary.get("is_obsolete")
99
100    def name(self):
101        return self._attachment_dictionary.get("name")
102
103    def review(self):
104        return self._attachment_dictionary.get("review")
105
106    def commit_queue(self):
107        return self._attachment_dictionary.get("commit-queue")
108
109    def url(self):
110        # FIXME: This should just return
111        # self._bugzilla().attachment_url_for_id(self.id()). scm_unittest.py
112        # depends on the current behavior.
113        return self._attachment_dictionary.get("url")
114
115    def _validate_flag_value(self, flag):
116        email = self._attachment_dictionary.get("%s_email" % flag)
117        if not email:
118            return None
119        committer = getattr(self._bugzilla().committers,
120                            "%s_by_email" % flag)(email)
121        if committer:
122            return committer
123        log("Warning, attachment %s on bug %s has invalid %s (%s)" % (
124                 self._attachment_dictionary['id'],
125                 self._attachment_dictionary['bug_id'], flag, email))
126
127    def reviewer(self):
128        if not self._reviewer:
129            self._reviewer = self._validate_flag_value("reviewer")
130        return self._reviewer
131
132    def committer(self):
133        if not self._committer:
134            self._committer = self._validate_flag_value("committer")
135        return self._committer
136
137
138class Bug(object):
139    # FIXME: This class is kinda a hack for now.  It exists so we have one
140    # place to hold bug logic, even if much of the code deals with
141    # dictionaries still.
142
143    def __init__(self, bug_dictionary, bugzilla):
144        self.bug_dictionary = bug_dictionary
145        self._bugzilla = bugzilla
146
147    def id(self):
148        return self.bug_dictionary["id"]
149
150    def assigned_to_email(self):
151        return self.bug_dictionary["assigned_to_email"]
152
153    # Rarely do we actually want obsolete attachments
154    def attachments(self, include_obsolete=False):
155        attachments = self.bug_dictionary["attachments"]
156        if not include_obsolete:
157            attachments = filter(lambda attachment:
158                                 not attachment["is_obsolete"], attachments)
159        return [Attachment(attachment, self) for attachment in attachments]
160
161    def patches(self, include_obsolete=False):
162        return [patch for patch in self.attachments(include_obsolete)
163                                   if patch.is_patch()]
164
165    def unreviewed_patches(self):
166        return [patch for patch in self.patches() if patch.review() == "?"]
167
168    def reviewed_patches(self, include_invalid=False):
169        patches = [patch for patch in self.patches() if patch.review() == "+"]
170        if include_invalid:
171            return patches
172        # Checking reviewer() ensures that it was both reviewed and has a valid
173        # reviewer.
174        return filter(lambda patch: patch.reviewer(), patches)
175
176    def commit_queued_patches(self, include_invalid=False):
177        patches = [patch for patch in self.patches()
178                                      if patch.commit_queue() == "+"]
179        if include_invalid:
180            return patches
181        # Checking committer() ensures that it was both commit-queue+'d and has
182        # a valid committer.
183        return filter(lambda patch: patch.committer(), patches)
184
185
186# A container for all of the logic for making and parsing buzilla queries.
187class BugzillaQueries(object):
188
189    def __init__(self, bugzilla):
190        self._bugzilla = bugzilla
191
192    # Note: _load_query and _fetch_bug are the only two methods which access
193    # self._bugzilla.
194
195    def _load_query(self, query):
196        self._bugzilla.authenticate()
197
198        full_url = "%s%s" % (self._bugzilla.bug_server_url, query)
199        return self._bugzilla.browser.open(full_url)
200
201    def _fetch_bug(self, bug_id):
202        return self._bugzilla.fetch_bug(bug_id)
203
204    def _fetch_bug_ids_advanced_query(self, query):
205        soup = BeautifulSoup(self._load_query(query))
206        # The contents of the <a> inside the cells in the first column happen
207        # to be the bug id.
208        return [int(bug_link_cell.find("a").string)
209                for bug_link_cell in soup('td', "first-child")]
210
211    def _parse_attachment_ids_request_query(self, page):
212        digits = re.compile("\d+")
213        attachment_href = re.compile("attachment.cgi\?id=\d+&action=review")
214        attachment_links = SoupStrainer("a", href=attachment_href)
215        return [int(digits.search(tag["href"]).group(0))
216                for tag in BeautifulSoup(page, parseOnlyThese=attachment_links)]
217
218    def _fetch_attachment_ids_request_query(self, query):
219        return self._parse_attachment_ids_request_query(self._load_query(query))
220
221    # List of all r+'d bugs.
222    def fetch_bug_ids_from_pending_commit_list(self):
223        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"
224        return self._fetch_bug_ids_advanced_query(needs_commit_query_url)
225
226    def fetch_patches_from_pending_commit_list(self):
227        return sum([self._fetch_bug(bug_id).reviewed_patches()
228            for bug_id in self.fetch_bug_ids_from_pending_commit_list()], [])
229
230    def fetch_bug_ids_from_commit_queue(self):
231        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"
232        return self._fetch_bug_ids_advanced_query(commit_queue_url)
233
234    def fetch_patches_from_commit_queue(self):
235        # This function will only return patches which have valid committers
236        # set.  It won't reject patches with invalid committers/reviewers.
237        return sum([self._fetch_bug(bug_id).commit_queued_patches()
238                    for bug_id in self.fetch_bug_ids_from_commit_queue()], [])
239
240    def _fetch_bug_ids_from_review_queue(self):
241        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?"
242        return self._fetch_bug_ids_advanced_query(review_queue_url)
243
244    def fetch_patches_from_review_queue(self, limit=None):
245        # [:None] returns the whole array.
246        return sum([self._fetch_bug(bug_id).unreviewed_patches()
247            for bug_id in self._fetch_bug_ids_from_review_queue()[:limit]], [])
248
249    # FIXME: Why do we have both fetch_patches_from_review_queue and
250    # fetch_attachment_ids_from_review_queue??
251    # NOTE: This is also the only client of _fetch_attachment_ids_request_query
252
253    def fetch_attachment_ids_from_review_queue(self):
254        review_queue_url = "request.cgi?action=queue&type=review&group=type"
255        return self._fetch_attachment_ids_request_query(review_queue_url)
256
257
258class CommitterValidator(object):
259
260    def __init__(self, bugzilla):
261        self._bugzilla = bugzilla
262
263    # _view_source_url belongs in some sort of webkit_config.py module.
264    def _view_source_url(self, local_path):
265        return "http://trac.webkit.org/browser/trunk/%s" % local_path
266
267    def _flag_permission_rejection_message(self, setter_email, flag_name):
268        # This could be computed from CommitterList.__file__
269        committer_list = "WebKitTools/Scripts/webkitpy/committers.py"
270        # Should come from some webkit_config.py
271        contribution_guidlines = "http://webkit.org/coding/contributing.html"
272        # This could be queried from the status_server.
273        queue_administrator = "eseidel@chromium.org"
274        # This could be queried from the tool.
275        queue_name = "commit-queue"
276        message = "%s does not have %s permissions according to %s." % (
277                        setter_email,
278                        flag_name,
279                        self._view_source_url(committer_list))
280        message += "\n\n- If you do not have %s rights please read %s for instructions on how to use bugzilla flags." % (
281                        flag_name, contribution_guidlines)
282        message += "\n\n- If you have %s rights please correct the error in %s by adding yourself to the file (no review needed).  " % (
283                        flag_name, committer_list)
284        message += "Due to bug 30084 the %s will require a restart after your change.  " % queue_name
285        message += "Please contact %s to request a %s restart.  " % (
286                        queue_administrator, queue_name)
287        message += "After restart the %s will correctly respect your %s rights." % (
288                        queue_name, flag_name)
289        return message
290
291    def _validate_setter_email(self, patch, result_key, rejection_function):
292        committer = getattr(patch, result_key)()
293        # If the flag is set, and we don't recognize the setter, reject the
294        # flag!
295        setter_email = patch._attachment_dictionary.get("%s_email" % result_key)
296        if setter_email and not committer:
297            rejection_function(patch.id(),
298                self._flag_permission_rejection_message(setter_email,
299                                                        result_key))
300            return False
301        return True
302
303    def patches_after_rejecting_invalid_commiters_and_reviewers(self, patches):
304        validated_patches = []
305        for patch in patches:
306            if (self._validate_setter_email(
307                    patch, "reviewer", self.reject_patch_from_review_queue)
308                and self._validate_setter_email(
309                    patch, "committer", self.reject_patch_from_commit_queue)):
310                validated_patches.append(patch)
311        return validated_patches
312
313    def reject_patch_from_commit_queue(self,
314                                       attachment_id,
315                                       additional_comment_text=None):
316        comment_text = "Rejecting patch %s from commit-queue." % attachment_id
317        self._bugzilla.set_flag_on_attachment(attachment_id,
318                                              "commit-queue",
319                                              "-",
320                                              comment_text,
321                                              additional_comment_text)
322
323    def reject_patch_from_review_queue(self,
324                                       attachment_id,
325                                       additional_comment_text=None):
326        comment_text = "Rejecting patch %s from review queue." % attachment_id
327        self._bugzilla.set_flag_on_attachment(attachment_id,
328                                              'review',
329                                              '-',
330                                              comment_text,
331                                              additional_comment_text)
332
333
334class Bugzilla(object):
335
336    def __init__(self, dryrun=False, committers=CommitterList()):
337        self.dryrun = dryrun
338        self.authenticated = False
339        self.queries = BugzillaQueries(self)
340        self.committers = committers
341
342        # FIXME: We should use some sort of Browser mock object when in dryrun
343        # mode (to prevent any mistakes).
344        self.browser = Browser()
345        # Ignore bugs.webkit.org/robots.txt until we fix it to allow this
346        # script.
347        self.browser.set_handle_robots(False)
348
349    # FIXME: Much of this should go into some sort of config module:
350    bug_server_host = "bugs.webkit.org"
351    bug_server_regex = "https?://%s/" % re.sub('\.', '\\.', bug_server_host)
352    bug_server_url = "https://%s/" % bug_server_host
353    unassigned_email = "webkit-unassigned@lists.webkit.org"
354
355    def bug_url_for_bug_id(self, bug_id, xml=False):
356        content_type = "&ctype=xml" if xml else ""
357        return "%sshow_bug.cgi?id=%s%s" % (self.bug_server_url,
358                                           bug_id,
359                                           content_type)
360
361    def short_bug_url_for_bug_id(self, bug_id):
362        return "http://webkit.org/b/%s" % bug_id
363
364    def attachment_url_for_id(self, attachment_id, action="view"):
365        action_param = ""
366        if action and action != "view":
367            action_param = "&action=%s" % action
368        return "%sattachment.cgi?id=%s%s" % (self.bug_server_url,
369                                             attachment_id,
370                                             action_param)
371
372    def _parse_attachment_flag(self,
373                               element,
374                               flag_name,
375                               attachment,
376                               result_key):
377        flag = element.find('flag', attrs={'name': flag_name})
378        if flag:
379            attachment[flag_name] = flag['status']
380            if flag['status'] == '+':
381                attachment[result_key] = flag['setter']
382
383    def _parse_attachment_element(self, element, bug_id):
384        attachment = {}
385        attachment['bug_id'] = bug_id
386        attachment['is_obsolete'] = (element.has_key('isobsolete') and element['isobsolete'] == "1")
387        attachment['is_patch'] = (element.has_key('ispatch') and element['ispatch'] == "1")
388        attachment['id'] = int(element.find('attachid').string)
389        # FIXME: No need to parse out the url here.
390        attachment['url'] = self.attachment_url_for_id(attachment['id'])
391        attachment['name'] = unicode(element.find('desc').string)
392        attachment['attacher_email'] = str(element.find('attacher').string)
393        attachment['type'] = str(element.find('type').string)
394        self._parse_attachment_flag(
395                element, 'review', attachment, 'reviewer_email')
396        self._parse_attachment_flag(
397                element, 'commit-queue', attachment, 'committer_email')
398        return attachment
399
400    def _parse_bug_page(self, page):
401        soup = BeautifulSoup(page)
402        bug = {}
403        bug["id"] = int(soup.find("bug_id").string)
404        bug["title"] = unicode(soup.find("short_desc").string)
405        bug["reporter_email"] = str(soup.find("reporter").string)
406        bug["assigned_to_email"] = str(soup.find("assigned_to").string)
407        bug["cc_emails"] = [str(element.string)
408                            for element in soup.findAll('cc')]
409        bug["attachments"] = [self._parse_attachment_element(element, bug["id"]) for element in soup.findAll('attachment')]
410        return bug
411
412    # Makes testing fetch_*_from_bug() possible until we have a better
413    # BugzillaNetwork abstration.
414
415    def _fetch_bug_page(self, bug_id):
416        bug_url = self.bug_url_for_bug_id(bug_id, xml=True)
417        log("Fetching: %s" % bug_url)
418        return self.browser.open(bug_url)
419
420    def fetch_bug_dictionary(self, bug_id):
421        return self._parse_bug_page(self._fetch_bug_page(bug_id))
422
423    # FIXME: A BugzillaCache object should provide all these fetch_ methods.
424
425    def fetch_bug(self, bug_id):
426        return Bug(self.fetch_bug_dictionary(bug_id), self)
427
428    def _parse_bug_id_from_attachment_page(self, page):
429        # The "Up" relation happens to point to the bug.
430        up_link = BeautifulSoup(page).find('link', rel='Up')
431        if not up_link:
432            # This attachment does not exist (or you don't have permissions to
433            # view it).
434            return None
435        match = re.search("show_bug.cgi\?id=(?P<bug_id>\d+)", up_link['href'])
436        return int(match.group('bug_id'))
437
438    def bug_id_for_attachment_id(self, attachment_id):
439        self.authenticate()
440
441        attachment_url = self.attachment_url_for_id(attachment_id, 'edit')
442        log("Fetching: %s" % attachment_url)
443        page = self.browser.open(attachment_url)
444        return self._parse_bug_id_from_attachment_page(page)
445
446    # FIXME: This should just return Attachment(id), which should be able to
447    # lazily fetch needed data.
448
449    def fetch_attachment(self, attachment_id):
450        # We could grab all the attachment details off of the attachment edit
451        # page but we already have working code to do so off of the bugs page,
452        # so re-use that.
453        bug_id = self.bug_id_for_attachment_id(attachment_id)
454        if not bug_id:
455            return None
456        attachments = self.fetch_bug(bug_id).attachments(include_obsolete=True)
457        for attachment in attachments:
458            if attachment.id() == int(attachment_id):
459                return attachment
460        return None # This should never be hit.
461
462    def authenticate(self):
463        if self.authenticated:
464            return
465
466        if self.dryrun:
467            log("Skipping log in for dry run...")
468            self.authenticated = True
469            return
470
471        attempts = 0
472        while not self.authenticated:
473            attempts += 1
474            (username, password) = Credentials(
475                self.bug_server_host, git_prefix="bugzilla").read_credentials()
476
477            log("Logging in as %s..." % username)
478            self.browser.open(self.bug_server_url +
479                              "index.cgi?GoAheadAndLogIn=1")
480            self.browser.select_form(name="login")
481            self.browser['Bugzilla_login'] = username
482            self.browser['Bugzilla_password'] = password
483            response = self.browser.submit()
484
485            match = re.search("<title>(.+?)</title>", response.read())
486            # If the resulting page has a title, and it contains the word
487            # "invalid" assume it's the login failure page.
488            if match and re.search("Invalid", match.group(1), re.IGNORECASE):
489                errorMessage = "Bugzilla login failed: %s" % match.group(1)
490                # raise an exception only if this was the last attempt
491                if attempts < 5:
492                    log(errorMessage)
493                else:
494                    raise Exception(errorMessage)
495            else:
496                self.authenticated = True
497
498    def _fill_attachment_form(self,
499                              description,
500                              patch_file_object,
501                              comment_text=None,
502                              mark_for_review=False,
503                              mark_for_commit_queue=False,
504                              mark_for_landing=False, bug_id=None):
505        self.browser['description'] = description
506        self.browser['ispatch'] = ("1",)
507        self.browser['flag_type-1'] = ('?',) if mark_for_review else ('X',)
508
509        if mark_for_landing:
510            self.browser['flag_type-3'] = ('+',)
511        elif mark_for_commit_queue:
512            self.browser['flag_type-3'] = ('?',)
513        else:
514            self.browser['flag_type-3'] = ('X',)
515
516        if bug_id:
517            patch_name = "bug-%s-%s.patch" % (bug_id, timestamp())
518        else:
519            patch_name ="%s.patch" % timestamp()
520        self.browser.add_file(patch_file_object,
521                              "text/plain",
522                              patch_name,
523                              'data')
524
525    def add_patch_to_bug(self,
526                         bug_id,
527                         patch_file_object,
528                         description,
529                         comment_text=None,
530                         mark_for_review=False,
531                         mark_for_commit_queue=False,
532                         mark_for_landing=False):
533        self.authenticate()
534
535        log('Adding patch "%s" to %sshow_bug.cgi?id=%s' % (description,
536                                                           self.bug_server_url,
537                                                           bug_id))
538
539        if self.dryrun:
540            log(comment_text)
541            return
542
543        self.browser.open("%sattachment.cgi?action=enter&bugid=%s" % (
544                          self.bug_server_url, bug_id))
545        self.browser.select_form(name="entryform")
546        self._fill_attachment_form(description,
547                                   patch_file_object,
548                                   mark_for_review=mark_for_review,
549                                   mark_for_commit_queue=mark_for_commit_queue,
550                                   mark_for_landing=mark_for_landing,
551                                   bug_id=bug_id)
552        if comment_text:
553            log(comment_text)
554            self.browser['comment'] = comment_text
555        self.browser.submit()
556
557    def prompt_for_component(self, components):
558        log("Please pick a component:")
559        i = 0
560        for name in components:
561            i += 1
562            log("%2d. %s" % (i, name))
563        result = int(User.prompt("Enter a number: ")) - 1
564        return components[result]
565
566    def _check_create_bug_response(self, response_html):
567        match = re.search("<title>Bug (?P<bug_id>\d+) Submitted</title>",
568                          response_html)
569        if match:
570            return match.group('bug_id')
571
572        match = re.search(
573            '<div id="bugzilla-body">(?P<error_message>.+)<div id="footer">',
574            response_html,
575            re.DOTALL)
576        error_message = "FAIL"
577        if match:
578            text_lines = BeautifulSoup(
579                    match.group('error_message')).findAll(text=True)
580            error_message = "\n" + '\n'.join(
581                    ["  " + line.strip()
582                     for line in text_lines if line.strip()])
583        raise Exception("Bug not created: %s" % error_message)
584
585    def create_bug(self,
586                   bug_title,
587                   bug_description,
588                   component=None,
589                   patch_file_object=None,
590                   patch_description=None,
591                   cc=None,
592                   mark_for_review=False,
593                   mark_for_commit_queue=False):
594        self.authenticate()
595
596        log('Creating bug with title "%s"' % bug_title)
597        if self.dryrun:
598            log(bug_description)
599            return
600
601        self.browser.open(self.bug_server_url + "enter_bug.cgi?product=WebKit")
602        self.browser.select_form(name="Create")
603        component_items = self.browser.find_control('component').items
604        component_names = map(lambda item: item.name, component_items)
605        if not component:
606            component = "New Bugs"
607        if component not in component_names:
608            component = self.prompt_for_component(component_names)
609        self.browser['component'] = [component]
610        if cc:
611            self.browser['cc'] = cc
612        self.browser['short_desc'] = bug_title
613        self.browser['comment'] = bug_description
614
615        if patch_file_object:
616            self._fill_attachment_form(
617                    patch_description,
618                    patch_file_object,
619                    mark_for_review=mark_for_review,
620                    mark_for_commit_queue=mark_for_commit_queue)
621
622        response = self.browser.submit()
623
624        bug_id = self._check_create_bug_response(response.read())
625        log("Bug %s created." % bug_id)
626        log("%sshow_bug.cgi?id=%s" % (self.bug_server_url, bug_id))
627        return bug_id
628
629    def _find_select_element_for_flag(self, flag_name):
630        # FIXME: This will break if we ever re-order attachment flags
631        if flag_name == "review":
632            return self.browser.find_control(type='select', nr=0)
633        if flag_name == "commit-queue":
634            return self.browser.find_control(type='select', nr=1)
635        raise Exception("Don't know how to find flag named \"%s\"" % flag_name)
636
637    def clear_attachment_flags(self,
638                               attachment_id,
639                               additional_comment_text=None):
640        self.authenticate()
641
642        comment_text = "Clearing flags on attachment: %s" % attachment_id
643        if additional_comment_text:
644            comment_text += "\n\n%s" % additional_comment_text
645        log(comment_text)
646
647        if self.dryrun:
648            return
649
650        self.browser.open(self.attachment_url_for_id(attachment_id, 'edit'))
651        self.browser.select_form(nr=1)
652        self.browser.set_value(comment_text, name='comment', nr=0)
653        self._find_select_element_for_flag('review').value = ("X",)
654        self._find_select_element_for_flag('commit-queue').value = ("X",)
655        self.browser.submit()
656
657    def set_flag_on_attachment(self,
658                               attachment_id,
659                               flag_name,
660                               flag_value,
661                               comment_text,
662                               additional_comment_text):
663        # FIXME: We need a way to test this function on a live bugzilla
664        # instance.
665
666        self.authenticate()
667
668        if additional_comment_text:
669            comment_text += "\n\n%s" % additional_comment_text
670        log(comment_text)
671
672        if self.dryrun:
673            return
674
675        self.browser.open(self.attachment_url_for_id(attachment_id, 'edit'))
676        self.browser.select_form(nr=1)
677        self.browser.set_value(comment_text, name='comment', nr=0)
678        self._find_select_element_for_flag(flag_name).value = (flag_value,)
679        self.browser.submit()
680
681    # FIXME: All of these bug editing methods have a ridiculous amount of
682    # copy/paste code.
683
684    def obsolete_attachment(self, attachment_id, comment_text=None):
685        self.authenticate()
686
687        log("Obsoleting attachment: %s" % attachment_id)
688        if self.dryrun:
689            log(comment_text)
690            return
691
692        self.browser.open(self.attachment_url_for_id(attachment_id, 'edit'))
693        self.browser.select_form(nr=1)
694        self.browser.find_control('isobsolete').items[0].selected = True
695        # Also clear any review flag (to remove it from review/commit queues)
696        self._find_select_element_for_flag('review').value = ("X",)
697        self._find_select_element_for_flag('commit-queue').value = ("X",)
698        if comment_text:
699            log(comment_text)
700            # Bugzilla has two textareas named 'comment', one is somehow
701            # hidden.  We want the first.
702            self.browser.set_value(comment_text, name='comment', nr=0)
703        self.browser.submit()
704
705    def add_cc_to_bug(self, bug_id, email_address_list):
706        self.authenticate()
707
708        log("Adding %s to the CC list for bug %s" % (email_address_list,
709                                                     bug_id))
710        if self.dryrun:
711            return
712
713        self.browser.open(self.bug_url_for_bug_id(bug_id))
714        self.browser.select_form(name="changeform")
715        self.browser["newcc"] = ", ".join(email_address_list)
716        self.browser.submit()
717
718    def post_comment_to_bug(self, bug_id, comment_text, cc=None):
719        self.authenticate()
720
721        log("Adding comment to bug %s" % bug_id)
722        if self.dryrun:
723            log(comment_text)
724            return
725
726        self.browser.open(self.bug_url_for_bug_id(bug_id))
727        self.browser.select_form(name="changeform")
728        self.browser["comment"] = comment_text
729        if cc:
730            self.browser["newcc"] = ", ".join(cc)
731        self.browser.submit()
732
733    def close_bug_as_fixed(self, bug_id, comment_text=None):
734        self.authenticate()
735
736        log("Closing bug %s as fixed" % bug_id)
737        if self.dryrun:
738            log(comment_text)
739            return
740
741        self.browser.open(self.bug_url_for_bug_id(bug_id))
742        self.browser.select_form(name="changeform")
743        if comment_text:
744            log(comment_text)
745            self.browser['comment'] = comment_text
746        self.browser['bug_status'] = ['RESOLVED']
747        self.browser['resolution'] = ['FIXED']
748        self.browser.submit()
749
750    def reassign_bug(self, bug_id, assignee, comment_text=None):
751        self.authenticate()
752
753        log("Assigning bug %s to %s" % (bug_id, assignee))
754        if self.dryrun:
755            log(comment_text)
756            return
757
758        self.browser.open(self.bug_url_for_bug_id(bug_id))
759        self.browser.select_form(name="changeform")
760        if comment_text:
761            log(comment_text)
762            self.browser["comment"] = comment_text
763        self.browser["assigned_to"] = assignee
764        self.browser.submit()
765
766    def reopen_bug(self, bug_id, comment_text):
767        self.authenticate()
768
769        log("Re-opening bug %s" % bug_id)
770        # Bugzilla requires a comment when re-opening a bug, so we know it will
771        # never be None.
772        log(comment_text)
773        if self.dryrun:
774            return
775
776        self.browser.open(self.bug_url_for_bug_id(bug_id))
777        self.browser.select_form(name="changeform")
778        bug_status = self.browser.find_control("bug_status", type="select")
779        # This is a hack around the fact that ClientForm.ListControl seems to
780        # have no simpler way to ask if a control has an item named "REOPENED"
781        # without using exceptions for control flow.
782        possible_bug_statuses = map(lambda item: item.name, bug_status.items)
783        if "REOPENED" in possible_bug_statuses:
784            bug_status.value = ["REOPENED"]
785        else:
786            log("Did not reopen bug %s.  " +
787                "It appears to already be open with status %s." % (
788                        bug_id, bug_status.value))
789        self.browser['comment'] = comment_text
790        self.browser.submit()
791