2011-01-18 Dirk Pranke <dpranke@chromium.org>
[WebKit.git] / Tools / Scripts / webkitpy / tool / commands / queues.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 from __future__ import with_statement
31
32 import codecs
33 import time
34 import traceback
35 import os
36
37 from datetime import datetime
38 from optparse import make_option
39 from StringIO import StringIO
40
41 from webkitpy.common.config.committervalidator import CommitterValidator
42 from webkitpy.common.net.bugzilla import Attachment
43 from webkitpy.common.net.layouttestresults import LayoutTestResults
44 from webkitpy.common.net.statusserver import StatusServer
45 from webkitpy.common.system.deprecated_logging import error, log
46 from webkitpy.common.system.executive import ScriptError
47 from webkitpy.tool.bot.commitqueuetask import CommitQueueTask, CommitQueueTaskDelegate
48 from webkitpy.tool.bot.feeders import CommitQueueFeeder, EWSFeeder
49 from webkitpy.tool.bot.queueengine import QueueEngine, QueueEngineDelegate
50 from webkitpy.tool.bot.flakytestreporter import FlakyTestReporter
51 from webkitpy.tool.commands.stepsequence import StepSequenceErrorHandler
52 from webkitpy.tool.multicommandtool import Command, TryAgain
53
54
55 class AbstractQueue(Command, QueueEngineDelegate):
56     watchers = [
57     ]
58
59     _pass_status = "Pass"
60     _fail_status = "Fail"
61     _retry_status = "Retry"
62     _error_status = "Error"
63
64     def __init__(self, options=None): # Default values should never be collections (like []) as default values are shared between invocations
65         options_list = (options or []) + [
66             make_option("--no-confirm", action="store_false", dest="confirm", default=True, help="Do not ask the user for confirmation before running the queue.  Dangerous!"),
67             make_option("--exit-after-iteration", action="store", type="int", dest="iterations", default=None, help="Stop running the queue after iterating this number of times."),
68         ]
69         Command.__init__(self, "Run the %s" % self.name, options=options_list)
70         self._iteration_count = 0
71
72     def _cc_watchers(self, bug_id):
73         try:
74             self._tool.bugs.add_cc_to_bug(bug_id, self.watchers)
75         except Exception, e:
76             traceback.print_exc()
77             log("Failed to CC watchers.")
78
79     def run_webkit_patch(self, args):
80         webkit_patch_args = [self._tool.path()]
81         # FIXME: This is a hack, we should have a more general way to pass global options.
82         # FIXME: We must always pass global options and their value in one argument
83         # because our global option code looks for the first argument which does
84         # not begin with "-" and assumes that is the command name.
85         webkit_patch_args += ["--status-host=%s" % self._tool.status_server.host]
86         if self._tool.status_server.bot_id:
87             webkit_patch_args += ["--bot-id=%s" % self._tool.status_server.bot_id]
88         if self._options.port:
89             webkit_patch_args += ["--port=%s" % self._options.port]
90         webkit_patch_args.extend(args)
91         # FIXME: There is probably no reason to use run_and_throw_if_fail anymore.
92         # run_and_throw_if_fail was invented to support tee'd output
93         # (where we write both to a log file and to the console at once),
94         # but the queues don't need live-progress, a dump-of-output at the
95         # end should be sufficient.
96         return self._tool.executive.run_and_throw_if_fail(webkit_patch_args)
97
98     def _log_directory(self):
99         return os.path.join("..", "%s-logs" % self.name)
100
101     # QueueEngineDelegate methods
102
103     def queue_log_path(self):
104         return os.path.join(self._log_directory(), "%s.log" % self.name)
105
106     def work_item_log_path(self, work_item):
107         raise NotImplementedError, "subclasses must implement"
108
109     def begin_work_queue(self):
110         log("CAUTION: %s will discard all local changes in \"%s\"" % (self.name, self._tool.scm().checkout_root))
111         if self._options.confirm:
112             response = self._tool.user.prompt("Are you sure?  Type \"yes\" to continue: ")
113             if (response != "yes"):
114                 error("User declined.")
115         log("Running WebKit %s." % self.name)
116         self._tool.status_server.update_status(self.name, "Starting Queue")
117
118     def stop_work_queue(self, reason):
119         self._tool.status_server.update_status(self.name, "Stopping Queue, reason: %s" % reason)
120
121     def should_continue_work_queue(self):
122         self._iteration_count += 1
123         return not self._options.iterations or self._iteration_count <= self._options.iterations
124
125     def next_work_item(self):
126         raise NotImplementedError, "subclasses must implement"
127
128     def should_proceed_with_work_item(self, work_item):
129         raise NotImplementedError, "subclasses must implement"
130
131     def process_work_item(self, work_item):
132         raise NotImplementedError, "subclasses must implement"
133
134     def handle_unexpected_error(self, work_item, message):
135         raise NotImplementedError, "subclasses must implement"
136
137     # Command methods
138
139     def execute(self, options, args, tool, engine=QueueEngine):
140         self._options = options # FIXME: This code is wrong.  Command.options is a list, this assumes an Options element!
141         self._tool = tool  # FIXME: This code is wrong too!  Command.bind_to_tool handles this!
142         return engine(self.name, self, self._tool.wakeup_event).run()
143
144     @classmethod
145     def _log_from_script_error_for_upload(cls, script_error, output_limit=None):
146         # We have seen request timeouts with app engine due to large
147         # log uploads.  Trying only the last 512k.
148         if not output_limit:
149             output_limit = 512 * 1024  # 512k
150         output = script_error.message_with_output(output_limit=output_limit)
151         # We pre-encode the string to a byte array before passing it
152         # to status_server, because ClientForm (part of mechanize)
153         # wants a file-like object with pre-encoded data.
154         return StringIO(output.encode("utf-8"))
155
156     @classmethod
157     def _update_status_for_script_error(cls, tool, state, script_error, is_error=False):
158         message = str(script_error)
159         if is_error:
160             message = "Error: %s" % message
161         failure_log = cls._log_from_script_error_for_upload(script_error)
162         return tool.status_server.update_status(cls.name, message, state["patch"], failure_log)
163
164
165 class FeederQueue(AbstractQueue):
166     name = "feeder-queue"
167
168     _sleep_duration = 30  # seconds
169
170     # AbstractPatchQueue methods
171
172     def begin_work_queue(self):
173         AbstractQueue.begin_work_queue(self)
174         self.feeders = [
175             CommitQueueFeeder(self._tool),
176             EWSFeeder(self._tool),
177         ]
178
179     def next_work_item(self):
180         # This really show inherit from some more basic class that doesn't
181         # understand work items, but the base class in the heirarchy currently
182         # understands work items.
183         return "synthetic-work-item"
184
185     def should_proceed_with_work_item(self, work_item):
186         return True
187
188     def process_work_item(self, work_item):
189         for feeder in self.feeders:
190             feeder.feed()
191         time.sleep(self._sleep_duration)
192         return True
193
194     def work_item_log_path(self, work_item):
195         return None
196
197     def handle_unexpected_error(self, work_item, message):
198         log(message)
199
200
201 class AbstractPatchQueue(AbstractQueue):
202     def _update_status(self, message, patch=None, results_file=None):
203         return self._tool.status_server.update_status(self.name, message, patch, results_file)
204
205     def _next_patch(self):
206         patch_id = self._tool.status_server.next_work_item(self.name)
207         if not patch_id:
208             return None
209         patch = self._tool.bugs.fetch_attachment(patch_id)
210         if not patch:
211             # FIXME: Using a fake patch because release_work_item has the wrong API.
212             # We also don't really need to release the lock (although that's fine),
213             # mostly we just need to remove this bogus patch from our queue.
214             # If for some reason bugzilla is just down, then it will be re-fed later.
215             patch = Attachment({'id': patch_id}, None)
216             self._release_work_item(patch)
217             return None
218         return patch
219
220     def _release_work_item(self, patch):
221         self._tool.status_server.release_work_item(self.name, patch)
222
223     def _did_pass(self, patch):
224         self._update_status(self._pass_status, patch)
225         self._release_work_item(patch)
226
227     def _did_fail(self, patch):
228         self._update_status(self._fail_status, patch)
229         self._release_work_item(patch)
230
231     def _did_retry(self, patch):
232         self._update_status(self._retry_status, patch)
233         self._release_work_item(patch)
234
235     def _did_error(self, patch, reason):
236         message = "%s: %s" % (self._error_status, reason)
237         self._update_status(message, patch)
238         self._release_work_item(patch)
239
240     def work_item_log_path(self, patch):
241         return os.path.join(self._log_directory(), "%s.log" % patch.bug_id())
242
243
244 class CommitQueue(AbstractPatchQueue, StepSequenceErrorHandler, CommitQueueTaskDelegate):
245     name = "commit-queue"
246
247     # AbstractPatchQueue methods
248
249     def begin_work_queue(self):
250         AbstractPatchQueue.begin_work_queue(self)
251         self.committer_validator = CommitterValidator(self._tool.bugs)
252
253     def next_work_item(self):
254         return self._next_patch()
255
256     def should_proceed_with_work_item(self, patch):
257         patch_text = "rollout patch" if patch.is_rollout() else "patch"
258         self._update_status("Processing %s" % patch_text, patch)
259         return True
260
261     def process_work_item(self, patch):
262         self._cc_watchers(patch.bug_id())
263         task = CommitQueueTask(self, patch)
264         try:
265             if task.run():
266                 self._did_pass(patch)
267                 return True
268             self._did_retry(patch)
269         except ScriptError, e:
270             validator = CommitterValidator(self._tool.bugs)
271             validator.reject_patch_from_commit_queue(patch.id(), self._error_message_for_bug(task.failure_status_id, e))
272             self._did_fail(patch)
273
274     def _error_message_for_bug(self, status_id, script_error):
275         if not script_error.output:
276             return script_error.message_with_output()
277         results_link = self._tool.status_server.results_url_for_status(status_id)
278         return "%s\nFull output: %s" % (script_error.message_with_output(), results_link)
279
280     def handle_unexpected_error(self, patch, message):
281         self.committer_validator.reject_patch_from_commit_queue(patch.id(), message)
282
283     # CommitQueueTaskDelegate methods
284
285     def run_command(self, command):
286         self.run_webkit_patch(command)
287
288     def command_passed(self, message, patch):
289         self._update_status(message, patch=patch)
290
291     def command_failed(self, message, script_error, patch):
292         failure_log = self._log_from_script_error_for_upload(script_error)
293         return self._update_status(message, patch=patch, results_file=failure_log)
294
295     # FIXME: This exists for mocking, but should instead be mocked via
296     # tool.filesystem.read_text_file.  They have different error handling at the moment.
297     def _read_file_contents(self, path):
298         try:
299             with codecs.open(path, "r", "utf-8") as open_file:
300                 return open_file.read()
301         except OSError, e:  # File does not exist or can't be read.
302             return None
303
304     # FIXME: This may belong on the Port object.
305     def layout_test_results(self):
306         results_path = self._tool.port().layout_tests_results_path()
307         results_html = self._read_file_contents(results_path)
308         if not results_html:
309             return None
310         return LayoutTestResults.results_from_string(results_html)
311
312     def _results_directory(self):
313         results_path = self._tool.port().layout_tests_results_path()
314         # FIXME: This is wrong in two ways:
315         # 1. It assumes that results.html is at the top level of the results tree.
316         # 2. This uses the "old" ports.py infrastructure instead of the new layout_tests/port
317         # which will not support Chromium.  However the new arch doesn't work with old-run-webkit-tests
318         # so we have to use this for now.
319         return os.path.dirname(results_path)
320
321     def archive_last_layout_test_results(self, patch):
322         results_directory = self._results_directory()
323         results_name, _ = os.path.splitext(os.path.basename(results_directory))
324         # Note: We name the zip with the bug_id instead of patch_id to match work_item_log_path().
325         zip_path = self._tool.workspace.find_unused_filename(self._log_directory(), "%s-%s" % (patch.bug_id(), results_name), "zip")
326         archive = self._tool.workspace.create_zip(zip_path, results_directory)
327         # Remove the results directory to prevent http logs, etc. from getting huge between runs.
328         # We could have create_zip remove the original, but this is more explicit.
329         self._tool.filesystem.rmtree(results_directory)
330         return archive
331
332     def refetch_patch(self, patch):
333         return self._tool.bugs.fetch_attachment(patch.id())
334
335     def report_flaky_tests(self, patch, flaky_test_results, results_archive=None):
336         reporter = FlakyTestReporter(self._tool, self.name)
337         reporter.report_flaky_tests(patch, flaky_test_results, results_archive)
338
339     # StepSequenceErrorHandler methods
340
341     def handle_script_error(cls, tool, state, script_error):
342         # Hitting this error handler should be pretty rare.  It does occur,
343         # however, when a patch no longer applies to top-of-tree in the final
344         # land step.
345         log(script_error.message_with_output())
346
347     @classmethod
348     def handle_checkout_needs_update(cls, tool, state, options, error):
349         message = "Tests passed, but commit failed (checkout out of date).  Updating, then landing without building or re-running tests."
350         tool.status_server.update_status(cls.name, message, state["patch"])
351         # The only time when we find out that out checkout needs update is
352         # when we were ready to actually pull the trigger and land the patch.
353         # Rather than spinning in the master process, we retry without
354         # building or testing, which is much faster.
355         options.build = False
356         options.test = False
357         options.update = True
358         raise TryAgain()
359
360
361 class AbstractReviewQueue(AbstractPatchQueue, StepSequenceErrorHandler):
362     """This is the base-class for the EWS queues and the style-queue."""
363     def __init__(self, options=None):
364         AbstractPatchQueue.__init__(self, options)
365
366     def review_patch(self, patch):
367         raise NotImplementedError("subclasses must implement")
368
369     # AbstractPatchQueue methods
370
371     def begin_work_queue(self):
372         AbstractPatchQueue.begin_work_queue(self)
373
374     def next_work_item(self):
375         return self._next_patch()
376
377     def should_proceed_with_work_item(self, patch):
378         raise NotImplementedError("subclasses must implement")
379
380     def process_work_item(self, patch):
381         try:
382             if not self.review_patch(patch):
383                 return False
384             self._did_pass(patch)
385             return True
386         except ScriptError, e:
387             if e.exit_code != QueueEngine.handled_error_code:
388                 self._did_fail(patch)
389             else:
390                 # The subprocess handled the error, but won't have released the patch, so we do.
391                 # FIXME: We need to simplify the rules by which _release_work_item is called.
392                 self._release_work_item(patch)
393             raise e
394
395     def handle_unexpected_error(self, patch, message):
396         log(message)
397
398     # StepSequenceErrorHandler methods
399
400     @classmethod
401     def handle_script_error(cls, tool, state, script_error):
402         log(script_error.message_with_output())
403
404
405 class StyleQueue(AbstractReviewQueue):
406     name = "style-queue"
407     def __init__(self):
408         AbstractReviewQueue.__init__(self)
409
410     def should_proceed_with_work_item(self, patch):
411         self._update_status("Checking style", patch)
412         return True
413
414     def review_patch(self, patch):
415         self.run_webkit_patch(["check-style", "--force-clean", "--non-interactive", "--parent-command=style-queue", patch.id()])
416         return True
417
418     @classmethod
419     def handle_script_error(cls, tool, state, script_error):
420         is_svn_apply = script_error.command_name() == "svn-apply"
421         status_id = cls._update_status_for_script_error(tool, state, script_error, is_error=is_svn_apply)
422         if is_svn_apply:
423             QueueEngine.exit_after_handled_error(script_error)
424         message = "Attachment %s did not pass %s:\n\n%s\n\nIf any of these errors are false positives, please file a bug against check-webkit-style." % (state["patch"].id(), cls.name, script_error.message_with_output(output_limit=3*1024))
425         tool.bugs.post_comment_to_bug(state["patch"].bug_id(), message, cc=cls.watchers)
426         exit(1)