2010-01-04 Eric Seidel <eric@webkit.org>
[WebKit-https.git] / WebKitTools / Scripts / webkitpy / bugzilla.py
1 # Copyright (c) 2009, Google Inc. All rights reserved.
2 # Copyright (c) 2009 Apple Inc. All rights reserved.
3
4 # Redistribution and use in source and binary forms, with or without
5 # modification, are permitted provided that the following conditions are
6 # met:
7
8 #     * Redistributions of source code must retain the above copyright
9 # notice, this list of conditions and the following disclaimer.
10 #     * Redistributions in binary form must reproduce the above
11 # copyright notice, this list of conditions and the following disclaimer
12 # in the documentation and/or other materials provided with the
13 # distribution.
14 #     * Neither the name of Google Inc. nor the names of its
15 # contributors may be used to endorse or promote products derived from
16 # this software without specific prior written permission.
17
18 # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
19 # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
20 # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
21 # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
22 # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
23 # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
24 # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
25 # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
26 # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
27 # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
28 # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29 #
30 # WebKit's Python module for interacting with Bugzilla
31
32 import re
33 import subprocess
34
35 from datetime import datetime # used in timestamp()
36
37 # Import WebKit-specific modules.
38 from webkitpy.webkit_logging import error, log
39 from webkitpy.committers import CommitterList
40 from webkitpy.credentials import Credentials
41
42 # WebKit includes a built copy of BeautifulSoup in Scripts/webkitpy
43 # so this import should always succeed.
44 from .BeautifulSoup import BeautifulSoup, SoupStrainer
45
46 from mechanize import Browser
47
48 def parse_bug_id(message):
49     match = re.search("http\://webkit\.org/b/(?P<bug_id>\d+)", message)
50     if match:
51         return int(match.group('bug_id'))
52     match = re.search(Bugzilla.bug_server_regex + "show_bug\.cgi\?id=(?P<bug_id>\d+)", message)
53     if match:
54         return int(match.group('bug_id'))
55     return None
56
57
58 def timestamp():
59     return datetime.now().strftime("%Y%m%d%H%M%S")
60
61
62 # FIXME: This class is kinda a hack for now.  It exists so we have one place
63 # to hold bug logic, even if much of the code deals with dictionaries still.
64 class Bug(object):
65     def __init__(self, bug_dictionary):
66         self.bug_dictionary = bug_dictionary
67
68     def assigned_to_email(self):
69         return self.bug_dictionary["assigned_to_email"]
70
71     # Rarely do we actually want obsolete attachments
72     def attachments(self, include_obsolete=False):
73         if include_obsolete:
74             return self.bug_dictionary["attachments"][:] # Return a copy in either case.
75         return [attachment for attachment in self.bug_dictionary["attachments"] if not attachment["is_obsolete"]]
76
77     def patches(self, include_obsolete=False):
78         return [patch for patch in self.attachments(include_obsolete) if patch["is_patch"]]
79
80     def unreviewed_patches(self):
81         return [patch for patch in self.patches() if patch.get("review") == "?"]
82
83
84 # A container for all of the logic for making a parsing buzilla queries.
85 class BugzillaQueries(object):
86     def __init__(self, bugzilla):
87         self.bugzilla = bugzilla
88
89     def _load_query(self, query):
90         full_url = "%s%s" % (self.bugzilla.bug_server_url, query)
91         return self.bugzilla.browser.open(full_url)
92
93     def _fetch_bug_ids_advanced_query(self, query):
94         soup = BeautifulSoup(self._load_query(query))
95         # The contents of the <a> inside the cells in the first column happen to be the bug id.
96         return [int(bug_link_cell.find("a").string) for bug_link_cell in soup('td', "first-child")]
97
98     def _parse_attachment_ids_request_query(self, page):
99         digits = re.compile("\d+")
100         attachment_href = re.compile("attachment.cgi\?id=\d+&action=review")
101         attachment_links = SoupStrainer("a", href=attachment_href)
102         return [int(digits.search(tag["href"]).group(0)) for tag in BeautifulSoup(page, parseOnlyThese=attachment_links)]
103
104     def _fetch_attachment_ids_request_query(self, query):
105         return self._parse_attachment_ids_request_query(self._load_query(query))
106
107     # List of all r+'d bugs.
108     def fetch_bug_ids_from_pending_commit_list(self):
109         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"
110         return self._fetch_bug_ids_advanced_query(needs_commit_query_url)
111
112     def fetch_patches_from_pending_commit_list(self):
113         # FIXME: This should not have to go through self.bugzilla
114         return sum([self.bugzilla.fetch_reviewed_patches_from_bug(bug_id) for bug_id in self.fetch_bug_ids_from_pending_commit_list()], [])
115
116     def fetch_bug_ids_from_commit_queue(self):
117         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"
118         return self._fetch_bug_ids_advanced_query(commit_queue_url)
119
120     def fetch_patches_from_commit_queue(self, reject_invalid_patches=False):
121         # FIXME: Once reject_invalid_patches is moved out of this function this becomes a simple list comprehension using fetch_bug_ids_from_commit_queue.
122         patches_to_land = []
123         for bug_id in self.fetch_bug_ids_from_commit_queue():
124             # FIXME: This should not have to go through self.bugzilla
125             patches = self.bugzilla.fetch_commit_queue_patches_from_bug(bug_id, reject_invalid_patches)
126             patches_to_land += patches
127         return patches_to_land
128
129     def _fetch_bug_ids_from_review_queue(self):
130         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?"
131         return self._fetch_bug_ids_advanced_query(review_queue_url)
132
133     def fetch_patches_from_review_queue(self, limit=None):
134         # FIXME: We should probably have a self.fetch_bug to minimize the number of self.bugzilla calls.
135         return sum([self.bugzilla.fetch_bug(bug_id).unreviewed_patches() for bug_id in self._fetch_bug_ids_from_review_queue()[:limit]], []) # [:None] returns the whole array.
136
137     # FIXME: Why do we have both fetch_patches_from_review_queue and fetch_attachment_ids_from_review_queue??
138     # NOTE: This is also the only client of _fetch_attachment_ids_request_query
139     def fetch_attachment_ids_from_review_queue(self):
140         review_queue_url = "request.cgi?action=queue&type=review&group=type"
141         return self._fetch_attachment_ids_request_query(review_queue_url)
142
143
144 class Bugzilla(object):
145     def __init__(self, dryrun=False, committers=CommitterList()):
146         self.dryrun = dryrun
147         self.authenticated = False
148         self.queries = BugzillaQueries(self)
149
150         # FIXME: We should use some sort of Browser mock object when in dryrun mode (to prevent any mistakes).
151         self.browser = Browser()
152         # Ignore bugs.webkit.org/robots.txt until we fix it to allow this script
153         self.browser.set_handle_robots(False)
154         self.committers = committers
155
156     # FIXME: Much of this should go into some sort of config module:
157     bug_server_host = "bugs.webkit.org"
158     bug_server_regex = "https?://%s/" % re.sub('\.', '\\.', bug_server_host)
159     bug_server_url = "https://%s/" % bug_server_host
160     unassigned_email = "webkit-unassigned@lists.webkit.org"
161
162     def bug_url_for_bug_id(self, bug_id, xml=False):
163         content_type = "&ctype=xml" if xml else ""
164         return "%sshow_bug.cgi?id=%s%s" % (self.bug_server_url, bug_id, content_type)
165
166     def short_bug_url_for_bug_id(self, bug_id):
167         return "http://webkit.org/b/%s" % bug_id
168
169     def attachment_url_for_id(self, attachment_id, action="view"):
170         action_param = ""
171         if action and action != "view":
172             action_param = "&action=%s" % action
173         return "%sattachment.cgi?id=%s%s" % (self.bug_server_url, attachment_id, action_param)
174
175     def _parse_attachment_flag(self, element, flag_name, attachment, result_key):
176         flag = element.find('flag', attrs={'name' : flag_name})
177         if flag:
178             attachment[flag_name] = flag['status']
179             if flag['status'] == '+':
180                 attachment[result_key] = flag['setter']
181
182     def _parse_attachment_element(self, element, bug_id):
183         attachment = {}
184         attachment['bug_id'] = bug_id
185         attachment['is_obsolete'] = (element.has_key('isobsolete') and element['isobsolete'] == "1")
186         attachment['is_patch'] = (element.has_key('ispatch') and element['ispatch'] == "1")
187         attachment['id'] = int(element.find('attachid').string)
188         attachment['url'] = self.attachment_url_for_id(attachment['id'])
189         attachment['name'] = unicode(element.find('desc').string)
190         attachment['attacher_email'] = str(element.find('attacher').string)
191         attachment['type'] = str(element.find('type').string)
192         self._parse_attachment_flag(element, 'review', attachment, 'reviewer_email')
193         self._parse_attachment_flag(element, 'commit-queue', attachment, 'committer_email')
194         return attachment
195
196     def _parse_bug_page(self, page):
197         soup = BeautifulSoup(page)
198         bug = {}
199         bug["id"] = int(soup.find("bug_id").string)
200         bug["title"] = unicode(soup.find("short_desc").string)
201         bug["reporter_email"] = str(soup.find("reporter").string)
202         bug["assigned_to_email"] = str(soup.find("assigned_to").string)
203         bug["cc_emails"] = [str(element.string) for element in soup.findAll('cc')]
204         bug["attachments"] = [self._parse_attachment_element(element, bug["id"]) for element in soup.findAll('attachment')]
205         return bug
206
207     # Makes testing fetch_*_from_bug() possible until we have a better BugzillaNetwork abstration.
208     def _fetch_bug_page(self, bug_id):
209         bug_url = self.bug_url_for_bug_id(bug_id, xml=True)
210         log("Fetching: %s" % bug_url)
211         return self.browser.open(bug_url)
212
213     def fetch_bug_dictionary(self, bug_id):
214         return self._parse_bug_page(self._fetch_bug_page(bug_id))
215
216     # FIXME: A BugzillaCache object should provide all these fetch_ methods.
217     def fetch_bug(self, bug_id):
218         return Bug(self.fetch_bug_dictionary(bug_id))
219
220     def _parse_bug_id_from_attachment_page(self, page):
221         up_link = BeautifulSoup(page).find('link', rel='Up') # The "Up" relation happens to point to the bug.
222         if not up_link:
223             return None # This attachment does not exist (or you don't have permissions to view it).
224         match = re.search("show_bug.cgi\?id=(?P<bug_id>\d+)", up_link['href'])
225         return int(match.group('bug_id'))
226
227     def bug_id_for_attachment_id(self, attachment_id):
228         attachment_url = self.attachment_url_for_id(attachment_id, 'edit')
229         log("Fetching: %s" % attachment_url)
230         page = self.browser.open(attachment_url)
231         return self._parse_bug_id_from_attachment_page(page)
232
233     # This should really return an Attachment object
234     # which can lazily fetch any missing data.
235     def fetch_attachment(self, attachment_id):
236         # We could grab all the attachment details off of the attachment edit page
237         # but we already have working code to do so off of the bugs page, so re-use that.
238         bug_id = self.bug_id_for_attachment_id(attachment_id)
239         if not bug_id:
240             return None
241         for attachment in self.fetch_bug(bug_id).attachments(include_obsolete=True):
242             # FIXME: Once we have a real Attachment class we shouldn't paper over this possible comparison failure
243             # and we should remove the int() == int() hacks and leave it just ==.
244             if int(attachment['id']) == int(attachment_id):
245                 self._validate_committer_and_reviewer(attachment)
246                 return attachment
247         return None # This should never be hit.
248
249     # fetch_patches_from_bug exists until we expose a Bug class outside of bugzilla.py
250     def fetch_patches_from_bug(self, bug_id):
251         return self.fetch_bug(bug_id).patches()
252
253     # _view_source_link belongs in some sort of webkit_config.py module.
254     def _view_source_link(self, local_path):
255         return "http://trac.webkit.org/browser/trunk/%s" % local_path
256
257     def _flag_permission_rejection_message(self, setter_email, flag_name):
258         committer_list = "WebKitTools/Scripts/webkitpy/committers.py" # This could be computed from CommitterList.__file__
259         contribution_guidlines_url = "http://webkit.org/coding/contributing.html" # Should come from some webkit_config.py
260         queue_administrator = "eseidel@chromium.org" # This could be queried from the status_server.
261         queue_name = "commit-queue" # This could be queried from the tool.
262         rejection_message = "%s does not have %s permissions according to %s." % (setter_email, flag_name, self._view_source_link(committer_list))
263         rejection_message += "\n\n- If you do not have %s rights please read %s for instructions on how to use bugzilla flags." % (flag_name, contribution_guidlines_url)
264         rejection_message += "\n\n- If you have %s rights please correct the error in %s by adding yourself to the file (no review needed)." % (flag_name, committer_list)
265         rejection_message += "  Due to bug 30084 the %s will require a restart after your change." % queue_name
266         rejection_message += "  Please contact %s to request a %s restart." % (queue_administrator, queue_name)
267         rejection_message += "  After restart the %s will correctly respect your %s rights." % (queue_name, flag_name)
268         return rejection_message
269
270     def _validate_setter_email(self, patch, result_key, lookup_function, rejection_function, reject_invalid_patches):
271         setter_email = patch.get(result_key + '_email')
272         if not setter_email:
273             return None
274
275         committer = lookup_function(setter_email)
276         if committer:
277             patch[result_key] = committer.full_name
278             return patch[result_key]
279
280         if reject_invalid_patches:
281             rejection_function(patch['id'], self._flag_permission_rejection_message(setter_email, result_key))
282         else:
283             log("Warning, attachment %s on bug %s has invalid %s (%s)" % (patch['id'], patch['bug_id'], result_key, setter_email))
284         return None
285
286     def _validate_reviewer(self, patch, reject_invalid_patches):
287         return self._validate_setter_email(patch, 'reviewer', self.committers.reviewer_by_email, self.reject_patch_from_review_queue, reject_invalid_patches)
288
289     def _validate_committer(self, patch, reject_invalid_patches):
290         return self._validate_setter_email(patch, 'committer', self.committers.committer_by_email, self.reject_patch_from_commit_queue, reject_invalid_patches)
291
292     # FIXME: This is a hack until we have a real Attachment object.
293     # _validate_committer and _validate_reviewer fill in the 'reviewer' and 'committer'
294     # keys which other parts of the code expect to be filled in.
295     def _validate_committer_and_reviewer(self, patch):
296         self._validate_reviewer(patch, reject_invalid_patches=False)
297         self._validate_committer(patch, reject_invalid_patches=False)
298
299     # FIXME: fetch_reviewed_patches_from_bug and fetch_commit_queue_patches_from_bug
300     # should share more code and use list comprehensions.
301     def fetch_reviewed_patches_from_bug(self, bug_id, reject_invalid_patches=False):
302         reviewed_patches = []
303         for attachment in self.fetch_bug(bug_id).attachments():
304             if self._validate_reviewer(attachment, reject_invalid_patches):
305                 reviewed_patches.append(attachment)
306         return reviewed_patches
307
308     def fetch_commit_queue_patches_from_bug(self, bug_id, reject_invalid_patches=False):
309         commit_queue_patches = []
310         for attachment in self.fetch_reviewed_patches_from_bug(bug_id, reject_invalid_patches):
311             if self._validate_committer(attachment, reject_invalid_patches):
312                 commit_queue_patches.append(attachment)
313         return commit_queue_patches
314
315     def authenticate(self):
316         if self.authenticated:
317             return
318
319         if self.dryrun:
320             log("Skipping log in for dry run...")
321             self.authenticated = True
322             return
323
324         (username, password) = Credentials(self.bug_server_host, git_prefix="bugzilla").read_credentials()
325
326         log("Logging in as %s..." % username)
327         self.browser.open(self.bug_server_url + "index.cgi?GoAheadAndLogIn=1")
328         self.browser.select_form(name="login")
329         self.browser['Bugzilla_login'] = username
330         self.browser['Bugzilla_password'] = password
331         response = self.browser.submit()
332
333         match = re.search("<title>(.+?)</title>", response.read())
334         # If the resulting page has a title, and it contains the word "invalid" assume it's the login failure page.
335         if match and re.search("Invalid", match.group(1), re.IGNORECASE):
336             # FIXME: We could add the ability to try again on failure.
337             raise Exception("Bugzilla login failed: %s" % match.group(1))
338
339         self.authenticated = True
340
341     def _fill_attachment_form(self, description, patch_file_object, comment_text=None, mark_for_review=False, mark_for_commit_queue=False, bug_id=None):
342         self.browser['description'] = description
343         self.browser['ispatch'] = ("1",)
344         self.browser['flag_type-1'] = ('?',) if mark_for_review else ('X',)
345         self.browser['flag_type-3'] = ('?',) if mark_for_commit_queue else ('X',)
346         if bug_id:
347             patch_name = "bug-%s-%s.patch" % (bug_id, timestamp())
348         else:
349             patch_name ="%s.patch" % timestamp()
350         self.browser.add_file(patch_file_object, "text/plain", patch_name, 'data')
351
352     def add_patch_to_bug(self, bug_id, patch_file_object, description, comment_text=None, mark_for_review=False, mark_for_commit_queue=False):
353         self.authenticate()
354
355         log('Adding patch "%s" to %sshow_bug.cgi?id=%s' % (description, self.bug_server_url, bug_id))
356
357         if self.dryrun:
358             log(comment_text)
359             return
360
361         self.browser.open("%sattachment.cgi?action=enter&bugid=%s" % (self.bug_server_url, bug_id))
362         self.browser.select_form(name="entryform")
363         self._fill_attachment_form(description, patch_file_object, mark_for_review=mark_for_review, mark_for_commit_queue=mark_for_commit_queue, bug_id=bug_id)
364         if comment_text:
365             log(comment_text)
366             self.browser['comment'] = comment_text
367         self.browser.submit()
368
369     def prompt_for_component(self, components):
370         log("Please pick a component:")
371         i = 0
372         for name in components:
373             i += 1
374             log("%2d. %s" % (i, name))
375         result = int(raw_input("Enter a number: ")) - 1
376         return components[result]
377
378     def _check_create_bug_response(self, response_html):
379         match = re.search("<title>Bug (?P<bug_id>\d+) Submitted</title>", response_html)
380         if match:
381             return match.group('bug_id')
382
383         match = re.search('<div id="bugzilla-body">(?P<error_message>.+)<div id="footer">', response_html, re.DOTALL)
384         error_message = "FAIL"
385         if match:
386             text_lines = BeautifulSoup(match.group('error_message')).findAll(text=True)
387             error_message = "\n" + '\n'.join(["  " + line.strip() for line in text_lines if line.strip()])
388         raise Exception("Bug not created: %s" % error_message)
389
390     def create_bug(self, bug_title, bug_description, component=None, patch_file_object=None, patch_description=None, cc=None, mark_for_review=False, mark_for_commit_queue=False):
391         self.authenticate()
392
393         log('Creating bug with title "%s"' % bug_title)
394         if self.dryrun:
395             log(bug_description)
396             return
397
398         self.browser.open(self.bug_server_url + "enter_bug.cgi?product=WebKit")
399         self.browser.select_form(name="Create")
400         component_items = self.browser.find_control('component').items
401         component_names = map(lambda item: item.name, component_items)
402         if not component or component not in component_names:
403             component = self.prompt_for_component(component_names)
404         self.browser['component'] = [component]
405         if cc:
406             self.browser['cc'] = cc
407         self.browser['short_desc'] = bug_title
408         self.browser['comment'] = bug_description
409
410         if patch_file_object:
411             self._fill_attachment_form(patch_description, patch_file_object, mark_for_review=mark_for_review, mark_for_commit_queue=mark_for_commit_queue)
412
413         response = self.browser.submit()
414
415         bug_id = self._check_create_bug_response(response.read())
416         log("Bug %s created." % bug_id)
417         log("%sshow_bug.cgi?id=%s" % (self.bug_server_url, bug_id))
418         return bug_id
419
420     def _find_select_element_for_flag(self, flag_name):
421         # FIXME: This will break if we ever re-order attachment flags
422         if flag_name == "review":
423             return self.browser.find_control(type='select', nr=0)
424         if flag_name == "commit-queue":
425             return self.browser.find_control(type='select', nr=1)
426         raise Exception("Don't know how to find flag named \"%s\"" % flag_name)
427
428     def clear_attachment_flags(self, attachment_id, additional_comment_text=None):
429         self.authenticate()
430
431         comment_text = "Clearing flags on attachment: %s" % attachment_id
432         if additional_comment_text:
433             comment_text += "\n\n%s" % additional_comment_text
434         log(comment_text)
435
436         if self.dryrun:
437             return
438
439         self.browser.open(self.attachment_url_for_id(attachment_id, 'edit'))
440         self.browser.select_form(nr=1)
441         self.browser.set_value(comment_text, name='comment', nr=0)
442         self._find_select_element_for_flag('review').value = ("X",)
443         self._find_select_element_for_flag('commit-queue').value = ("X",)
444         self.browser.submit()
445
446     # FIXME: We need a way to test this on a live bugzilla instance.
447     def _set_flag_on_attachment(self, attachment_id, flag_name, flag_value, comment_text, additional_comment_text):
448         self.authenticate()
449
450         if additional_comment_text:
451             comment_text += "\n\n%s" % additional_comment_text
452         log(comment_text)
453
454         if self.dryrun:
455             return
456
457         self.browser.open(self.attachment_url_for_id(attachment_id, 'edit'))
458         self.browser.select_form(nr=1)
459         self.browser.set_value(comment_text, name='comment', nr=0)
460         self._find_select_element_for_flag(flag_name).value = (flag_value,)
461         self.browser.submit()
462
463     def reject_patch_from_commit_queue(self, attachment_id, additional_comment_text=None):
464         comment_text = "Rejecting patch %s from commit-queue." % attachment_id
465         self._set_flag_on_attachment(attachment_id, 'commit-queue', '-', comment_text, additional_comment_text)
466
467     def reject_patch_from_review_queue(self, attachment_id, additional_comment_text=None):
468         comment_text = "Rejecting patch %s from review queue." % attachment_id
469         self._set_flag_on_attachment(attachment_id, 'review', '-', comment_text, additional_comment_text)
470
471     # FIXME: All of these bug editing methods have a ridiculous amount of copy/paste code.
472     def obsolete_attachment(self, attachment_id, comment_text = None):
473         self.authenticate()
474
475         log("Obsoleting attachment: %s" % attachment_id)
476         if self.dryrun:
477             log(comment_text)
478             return
479
480         self.browser.open(self.attachment_url_for_id(attachment_id, 'edit'))
481         self.browser.select_form(nr=1)
482         self.browser.find_control('isobsolete').items[0].selected = True
483         # Also clear any review flag (to remove it from review/commit queues)
484         self._find_select_element_for_flag('review').value = ("X",)
485         self._find_select_element_for_flag('commit-queue').value = ("X",)
486         if comment_text:
487             log(comment_text)
488             # Bugzilla has two textareas named 'comment', one is somehow hidden.  We want the first.
489             self.browser.set_value(comment_text, name='comment', nr=0)
490         self.browser.submit()
491
492     def add_cc_to_bug(self, bug_id, email_address_list):
493         self.authenticate()
494
495         log("Adding %s to the CC list for bug %s" % (email_address_list, bug_id))
496         if self.dryrun:
497             return
498
499         self.browser.open(self.bug_url_for_bug_id(bug_id))
500         self.browser.select_form(name="changeform")
501         self.browser["newcc"] = ", ".join(email_address_list)
502         self.browser.submit()
503
504     def post_comment_to_bug(self, bug_id, comment_text, cc=None):
505         self.authenticate()
506
507         log("Adding comment to bug %s" % bug_id)
508         if self.dryrun:
509             log(comment_text)
510             return
511
512         self.browser.open(self.bug_url_for_bug_id(bug_id))
513         self.browser.select_form(name="changeform")
514         self.browser["comment"] = comment_text
515         if cc:
516             self.browser["newcc"] = ", ".join(cc)
517         self.browser.submit()
518
519     def close_bug_as_fixed(self, bug_id, comment_text=None):
520         self.authenticate()
521
522         log("Closing bug %s as fixed" % bug_id)
523         if self.dryrun:
524             log(comment_text)
525             return
526
527         self.browser.open(self.bug_url_for_bug_id(bug_id))
528         self.browser.select_form(name="changeform")
529         if comment_text:
530             log(comment_text)
531             self.browser['comment'] = comment_text
532         self.browser['bug_status'] = ['RESOLVED']
533         self.browser['resolution'] = ['FIXED']
534         self.browser.submit()
535
536     def reassign_bug(self, bug_id, assignee, comment_text=None):
537         self.authenticate()
538
539         log("Assigning bug %s to %s" % (bug_id, assignee))
540         if self.dryrun:
541             log(comment_text)
542             return
543
544         self.browser.open(self.bug_url_for_bug_id(bug_id))
545         self.browser.select_form(name="changeform")
546         if comment_text:
547             log(comment_text)
548             self.browser["comment"] = comment_text
549         self.browser["assigned_to"] = assignee
550         self.browser.submit()
551
552     def reopen_bug(self, bug_id, comment_text):
553         self.authenticate()
554
555         log("Re-opening bug %s" % bug_id)
556         log(comment_text) # Bugzilla requires a comment when re-opening a bug, so we know it will never be None.
557         if self.dryrun:
558             return
559
560         self.browser.open(self.bug_url_for_bug_id(bug_id))
561         self.browser.select_form(name="changeform")
562         self.browser['bug_status'] = ['REOPENED']
563         self.browser['comment'] = comment_text
564         self.browser.submit()