summaryrefslogtreecommitdiffstats
path: root/WebKitTools/Scripts/webkitpy/common
diff options
context:
space:
mode:
authorSteve Block <steveblock@google.com>2010-04-27 16:31:00 +0100
committerSteve Block <steveblock@google.com>2010-05-11 14:42:12 +0100
commitdcc8cf2e65d1aa555cce12431a16547e66b469ee (patch)
tree92a8d65cd5383bca9749f5327fb5e440563926e6 /WebKitTools/Scripts/webkitpy/common
parentccac38a6b48843126402088a309597e682f40fe6 (diff)
downloadexternal_webkit-dcc8cf2e65d1aa555cce12431a16547e66b469ee.zip
external_webkit-dcc8cf2e65d1aa555cce12431a16547e66b469ee.tar.gz
external_webkit-dcc8cf2e65d1aa555cce12431a16547e66b469ee.tar.bz2
Merge webkit.org at r58033 : Initial merge by git
Change-Id: If006c38561af287c50cd578d251629b51e4d8cd1
Diffstat (limited to 'WebKitTools/Scripts/webkitpy/common')
-rw-r--r--WebKitTools/Scripts/webkitpy/common/__init__.py1
-rw-r--r--WebKitTools/Scripts/webkitpy/common/checkout/__init__.py1
-rw-r--r--WebKitTools/Scripts/webkitpy/common/checkout/api.py140
-rw-r--r--WebKitTools/Scripts/webkitpy/common/checkout/api_unittest.py169
-rw-r--r--WebKitTools/Scripts/webkitpy/common/checkout/changelog.py181
-rw-r--r--WebKitTools/Scripts/webkitpy/common/checkout/changelog_unittest.py190
-rw-r--r--WebKitTools/Scripts/webkitpy/common/checkout/commitinfo.py95
-rw-r--r--WebKitTools/Scripts/webkitpy/common/checkout/commitinfo_unittest.py61
-rw-r--r--WebKitTools/Scripts/webkitpy/common/checkout/diff_parser.py165
-rw-r--r--WebKitTools/Scripts/webkitpy/common/checkout/diff_parser_unittest.py146
-rw-r--r--WebKitTools/Scripts/webkitpy/common/checkout/scm.py616
-rw-r--r--WebKitTools/Scripts/webkitpy/common/checkout/scm_unittest.py783
-rw-r--r--WebKitTools/Scripts/webkitpy/common/config/__init__.py7
-rw-r--r--WebKitTools/Scripts/webkitpy/common/config/committers.py288
-rw-r--r--WebKitTools/Scripts/webkitpy/common/config/committers_unittest.py72
-rw-r--r--WebKitTools/Scripts/webkitpy/common/config/irc.py31
-rw-r--r--WebKitTools/Scripts/webkitpy/common/config/ports.py190
-rw-r--r--WebKitTools/Scripts/webkitpy/common/config/ports_unittest.py68
-rw-r--r--WebKitTools/Scripts/webkitpy/common/net/__init__.py1
-rw-r--r--WebKitTools/Scripts/webkitpy/common/net/bugzilla.py835
-rw-r--r--WebKitTools/Scripts/webkitpy/common/net/bugzilla_unittest.py347
-rw-r--r--WebKitTools/Scripts/webkitpy/common/net/buildbot.py495
-rw-r--r--WebKitTools/Scripts/webkitpy/common/net/buildbot_unittest.py433
-rw-r--r--WebKitTools/Scripts/webkitpy/common/net/credentials.py126
-rw-r--r--WebKitTools/Scripts/webkitpy/common/net/credentials_unittest.py117
-rw-r--r--WebKitTools/Scripts/webkitpy/common/net/irc/__init__.py1
-rw-r--r--WebKitTools/Scripts/webkitpy/common/net/irc/ircbot.py91
-rw-r--r--WebKitTools/Scripts/webkitpy/common/net/irc/ircproxy.py62
-rw-r--r--WebKitTools/Scripts/webkitpy/common/net/irc/ircproxy_unittest.py43
-rw-r--r--WebKitTools/Scripts/webkitpy/common/net/networktransaction.py68
-rw-r--r--WebKitTools/Scripts/webkitpy/common/net/networktransaction_unittest.py86
-rw-r--r--WebKitTools/Scripts/webkitpy/common/net/rietveld.py89
-rw-r--r--WebKitTools/Scripts/webkitpy/common/net/rietveld_unittest.py39
-rw-r--r--WebKitTools/Scripts/webkitpy/common/net/statusserver.py108
-rw-r--r--WebKitTools/Scripts/webkitpy/common/prettypatch.py55
-rw-r--r--WebKitTools/Scripts/webkitpy/common/system/__init__.py1
-rwxr-xr-xWebKitTools/Scripts/webkitpy/common/system/autoinstall.py518
-rw-r--r--WebKitTools/Scripts/webkitpy/common/system/deprecated_logging.py85
-rw-r--r--WebKitTools/Scripts/webkitpy/common/system/deprecated_logging_unittest.py61
-rw-r--r--WebKitTools/Scripts/webkitpy/common/system/executive.py209
-rw-r--r--WebKitTools/Scripts/webkitpy/common/system/executive_unittest.py42
-rw-r--r--WebKitTools/Scripts/webkitpy/common/system/logtesting.py258
-rw-r--r--WebKitTools/Scripts/webkitpy/common/system/logutils.py207
-rw-r--r--WebKitTools/Scripts/webkitpy/common/system/logutils_unittest.py142
-rw-r--r--WebKitTools/Scripts/webkitpy/common/system/ospath.py83
-rw-r--r--WebKitTools/Scripts/webkitpy/common/system/ospath_unittest.py62
-rw-r--r--WebKitTools/Scripts/webkitpy/common/system/outputcapture.py62
-rw-r--r--WebKitTools/Scripts/webkitpy/common/system/user.py82
-rw-r--r--WebKitTools/Scripts/webkitpy/common/system/user_unittest.py54
-rw-r--r--WebKitTools/Scripts/webkitpy/common/thread/__init__.py1
-rw-r--r--WebKitTools/Scripts/webkitpy/common/thread/messagepump.py59
-rw-r--r--WebKitTools/Scripts/webkitpy/common/thread/messagepump_unittest.py83
-rw-r--r--WebKitTools/Scripts/webkitpy/common/thread/threadedmessagequeue.py55
-rw-r--r--WebKitTools/Scripts/webkitpy/common/thread/threadedmessagequeue_unittest.py53
54 files changed, 8317 insertions, 0 deletions
diff --git a/WebKitTools/Scripts/webkitpy/common/__init__.py b/WebKitTools/Scripts/webkitpy/common/__init__.py
new file mode 100644
index 0000000..ef65bee
--- /dev/null
+++ b/WebKitTools/Scripts/webkitpy/common/__init__.py
@@ -0,0 +1 @@
+# Required for Python to search this directory for module files
diff --git a/WebKitTools/Scripts/webkitpy/common/checkout/__init__.py b/WebKitTools/Scripts/webkitpy/common/checkout/__init__.py
new file mode 100644
index 0000000..ef65bee
--- /dev/null
+++ b/WebKitTools/Scripts/webkitpy/common/checkout/__init__.py
@@ -0,0 +1 @@
+# Required for Python to search this directory for module files
diff --git a/WebKitTools/Scripts/webkitpy/common/checkout/api.py b/WebKitTools/Scripts/webkitpy/common/checkout/api.py
new file mode 100644
index 0000000..c4e2b69
--- /dev/null
+++ b/WebKitTools/Scripts/webkitpy/common/checkout/api.py
@@ -0,0 +1,140 @@
+# Copyright (c) 2010 Google Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# * Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following disclaimer
+# in the documentation and/or other materials provided with the
+# distribution.
+# * Neither the name of Google Inc. nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import os
+import subprocess
+import StringIO
+
+from webkitpy.common.checkout.changelog import ChangeLog
+from webkitpy.common.checkout.commitinfo import CommitInfo
+from webkitpy.common.checkout.scm import CommitMessage
+from webkitpy.common.net.bugzilla import parse_bug_id
+from webkitpy.common.system.executive import Executive, run_command, ScriptError
+from webkitpy.common.system.deprecated_logging import log
+
+
+# This class represents the WebKit-specific parts of the checkout (like
+# ChangeLogs).
+# FIXME: Move a bunch of ChangeLog-specific processing from SCM to this object.
+class Checkout(object):
+ def __init__(self, scm):
+ self._scm = scm
+
+ def _is_path_to_changelog(self, path):
+ return os.path.basename(path) == "ChangeLog"
+
+ def _latest_entry_for_changelog_at_revision(self, changelog_path, revision):
+ changelog_contents = self._scm.contents_at_revision(changelog_path, revision)
+ return ChangeLog.parse_latest_entry_from_file(StringIO.StringIO(changelog_contents))
+
+ def changelog_entries_for_revision(self, revision):
+ changed_files = self._scm.changed_files_for_revision(revision)
+ return [self._latest_entry_for_changelog_at_revision(path, revision) for path in changed_files if self._is_path_to_changelog(path)]
+
+ def commit_info_for_revision(self, revision):
+ committer_email = self._scm.committer_email_for_revision(revision)
+ changelog_entries = self.changelog_entries_for_revision(revision)
+ # Assume for now that the first entry has everything we need:
+ # FIXME: This will throw an exception if there were no ChangeLogs.
+ if not len(changelog_entries):
+ return None
+ changelog_entry = changelog_entries[0]
+ changelog_data = {
+ "bug_id": parse_bug_id(changelog_entry.contents()),
+ "author_name": changelog_entry.author_name(),
+ "author_email": changelog_entry.author_email(),
+ "author": changelog_entry.author(),
+ "reviewer_text": changelog_entry.reviewer_text(),
+ "reviewer": changelog_entry.reviewer(),
+ }
+ # We could pass the changelog_entry instead of a dictionary here, but that makes
+ # mocking slightly more involved, and would make aggregating data from multiple
+ # entries more difficult to wire in if we need to do that in the future.
+ return CommitInfo(revision, committer_email, changelog_data)
+
+ def bug_id_for_revision(self, revision):
+ return self.commit_info_for_revision(revision).bug_id()
+
+ def modified_changelogs(self):
+ # SCM returns paths relative to scm.checkout_root
+ # Callers (especially those using the ChangeLog class) may
+ # expect absolute paths, so this method returns absolute paths.
+ changed_files = self._scm.changed_files()
+ absolute_paths = [os.path.join(self._scm.checkout_root, path) for path in changed_files]
+ return [path for path in absolute_paths if self._is_path_to_changelog(path)]
+
+ def commit_message_for_this_commit(self):
+ changelog_paths = self.modified_changelogs()
+ if not len(changelog_paths):
+ raise ScriptError(message="Found no modified ChangeLogs, cannot create a commit message.\n"
+ "All changes require a ChangeLog. See:\n"
+ "http://webkit.org/coding/contributing.html")
+
+ changelog_messages = []
+ for changelog_path in changelog_paths:
+ log("Parsing ChangeLog: %s" % changelog_path)
+ changelog_entry = ChangeLog(changelog_path).latest_entry()
+ if not changelog_entry:
+ raise ScriptError(message="Failed to parse ChangeLog: %s" % os.path.abspath(changelog_path))
+ changelog_messages.append(changelog_entry.contents())
+
+ # FIXME: We should sort and label the ChangeLog messages like commit-log-editor does.
+ return CommitMessage("".join(changelog_messages).splitlines())
+
+ def bug_id_for_this_commit(self):
+ try:
+ return parse_bug_id(self.commit_message_for_this_commit().message())
+ except ScriptError, e:
+ pass # We might not have ChangeLogs.
+
+ def apply_patch(self, patch, force=False):
+ # It's possible that the patch was not made from the root directory.
+ # We should detect and handle that case.
+ # FIXME: Use Executive instead of subprocess here.
+ curl_process = subprocess.Popen(['curl', '--location', '--silent', '--show-error', patch.url()], stdout=subprocess.PIPE)
+ # FIXME: Move _scm.script_path here once we get rid of all the dependencies.
+ args = [self._scm.script_path('svn-apply')]
+ if patch.reviewer():
+ args += ['--reviewer', patch.reviewer().full_name]
+ if force:
+ args.append('--force')
+
+ run_command(args, input=curl_process.stdout)
+
+ def apply_reverse_diff(self, revision):
+ self._scm.apply_reverse_diff(revision)
+
+ # We revert the ChangeLogs because removing lines from a ChangeLog
+ # doesn't make sense. ChangeLogs are append only.
+ changelog_paths = self.modified_changelogs()
+ if len(changelog_paths):
+ self._scm.revert_files(changelog_paths)
+
+ conflicts = self._scm.conflicted_files()
+ if len(conflicts):
+ raise ScriptError(message="Failed to apply reverse diff for revision %s because of the following conflicts:\n%s" % (revision, "\n".join(conflicts)))
diff --git a/WebKitTools/Scripts/webkitpy/common/checkout/api_unittest.py b/WebKitTools/Scripts/webkitpy/common/checkout/api_unittest.py
new file mode 100644
index 0000000..e99caee
--- /dev/null
+++ b/WebKitTools/Scripts/webkitpy/common/checkout/api_unittest.py
@@ -0,0 +1,169 @@
+# Copyright (C) 2010 Google Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# * Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following disclaimer
+# in the documentation and/or other materials provided with the
+# distribution.
+# * Neither the name of Google Inc. nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import os
+import shutil
+import tempfile
+import unittest
+
+from webkitpy.common.checkout.api import Checkout
+from webkitpy.common.checkout.changelog import ChangeLogEntry
+from webkitpy.common.checkout.scm import detect_scm_system, CommitMessage
+from webkitpy.common.system.outputcapture import OutputCapture
+from webkitpy.thirdparty.mock import Mock
+
+# FIXME: Copied from scm_unittest.py
+def write_into_file_at_path(file_path, contents):
+ new_file = open(file_path, 'w')
+ new_file.write(contents)
+ new_file.close()
+
+
+_changelog1entry1 = """2010-03-25 Eric Seidel <eric@webkit.org>
+
+ Unreviewed build fix to un-break webkit-patch land.
+
+ Move commit_message_for_this_commit from scm to checkout
+ https://bugs.webkit.org/show_bug.cgi?id=36629
+
+ * Scripts/webkitpy/common/checkout/api.py: import scm.CommitMessage
+"""
+_changelog1entry2 = """2010-03-25 Adam Barth <abarth@webkit.org>
+
+ Reviewed by Eric Seidel.
+
+ Move commit_message_for_this_commit from scm to checkout
+ https://bugs.webkit.org/show_bug.cgi?id=36629
+
+ * Scripts/webkitpy/common/checkout/api.py:
+"""
+_changelog1 = "\n".join([_changelog1entry1, _changelog1entry2])
+_changelog2 = """2010-03-25 Eric Seidel <eric@webkit.org>
+
+ Unreviewed build fix to un-break webkit-patch land.
+
+ Second part of this complicated change.
+
+ * Path/To/Complicated/File: Added.
+
+2010-03-25 Adam Barth <abarth@webkit.org>
+
+ Reviewed by Eric Seidel.
+
+ Filler change.
+"""
+
+class CommitMessageForThisCommitTest(unittest.TestCase):
+ expected_commit_message = """2010-03-25 Eric Seidel <eric@webkit.org>
+
+ Unreviewed build fix to un-break webkit-patch land.
+
+ Move commit_message_for_this_commit from scm to checkout
+ https://bugs.webkit.org/show_bug.cgi?id=36629
+
+ * Scripts/webkitpy/common/checkout/api.py: import scm.CommitMessage
+2010-03-25 Eric Seidel <eric@webkit.org>
+
+ Unreviewed build fix to un-break webkit-patch land.
+
+ Second part of this complicated change.
+
+ * Path/To/Complicated/File: Added.
+"""
+
+ def setUp(self):
+ self.temp_dir = tempfile.mkdtemp(suffix="changelogs")
+ self.old_cwd = os.getcwd()
+ os.chdir(self.temp_dir)
+ write_into_file_at_path("ChangeLog1", _changelog1)
+ write_into_file_at_path("ChangeLog2", _changelog2)
+
+ def tearDown(self):
+ shutil.rmtree(self.temp_dir, ignore_errors=True)
+ os.chdir(self.old_cwd)
+
+ # FIXME: This should not need to touch the file system, however
+ # ChangeLog is difficult to mock at current.
+ def test_commit_message_for_this_commit(self):
+ checkout = Checkout(None)
+ checkout.modified_changelogs = lambda: ["ChangeLog1", "ChangeLog2"]
+ output = OutputCapture()
+ expected_stderr = "Parsing ChangeLog: ChangeLog1\nParsing ChangeLog: ChangeLog2\n"
+ commit_message = output.assert_outputs(self, checkout.commit_message_for_this_commit, expected_stderr=expected_stderr)
+ self.assertEqual(commit_message.message(), self.expected_commit_message)
+
+
+class CheckoutTest(unittest.TestCase):
+ def test_latest_entry_for_changelog_at_revision(self):
+ scm = Mock()
+ def mock_contents_at_revision(changelog_path, revision):
+ self.assertEqual(changelog_path, "foo")
+ self.assertEqual(revision, "bar")
+ return _changelog1
+ scm.contents_at_revision = mock_contents_at_revision
+ checkout = Checkout(scm)
+ entry = checkout._latest_entry_for_changelog_at_revision("foo", "bar")
+ self.assertEqual(entry.contents(), _changelog1entry1)
+
+ def test_commit_info_for_revision(self):
+ scm = Mock()
+ scm.committer_email_for_revision = lambda revision: "committer@example.com"
+ checkout = Checkout(scm)
+ checkout.changelog_entries_for_revision = lambda revision: [ChangeLogEntry(_changelog1entry1)]
+ commitinfo = checkout.commit_info_for_revision(4)
+ self.assertEqual(commitinfo.bug_id(), 36629)
+ self.assertEqual(commitinfo.author_name(), "Eric Seidel")
+ self.assertEqual(commitinfo.author_email(), "eric@webkit.org")
+ self.assertEqual(commitinfo.reviewer_text(), None)
+ self.assertEqual(commitinfo.reviewer(), None)
+ self.assertEqual(commitinfo.committer_email(), "committer@example.com")
+ self.assertEqual(commitinfo.committer(), None)
+
+ checkout.changelog_entries_for_revision = lambda revision: []
+ self.assertEqual(checkout.commit_info_for_revision(1), None)
+
+ def test_bug_id_for_revision(self):
+ scm = Mock()
+ scm.committer_email_for_revision = lambda revision: "committer@example.com"
+ checkout = Checkout(scm)
+ checkout.changelog_entries_for_revision = lambda revision: [ChangeLogEntry(_changelog1entry1)]
+ self.assertEqual(checkout.bug_id_for_revision(4), 36629)
+
+ def test_bug_id_for_this_commit(self):
+ scm = Mock()
+ checkout = Checkout(scm)
+ checkout.commit_message_for_this_commit = lambda: CommitMessage(ChangeLogEntry(_changelog1entry1).contents().splitlines())
+ self.assertEqual(checkout.bug_id_for_this_commit(), 36629)
+
+ def test_modified_changelogs(self):
+ scm = Mock()
+ scm.checkout_root = "/foo/bar"
+ scm.changed_files = lambda:["file1", "ChangeLog", "relative/path/ChangeLog"]
+ checkout = Checkout(scm)
+ expected_changlogs = ["/foo/bar/ChangeLog", "/foo/bar/relative/path/ChangeLog"]
+ self.assertEqual(checkout.modified_changelogs(), expected_changlogs)
diff --git a/WebKitTools/Scripts/webkitpy/common/checkout/changelog.py b/WebKitTools/Scripts/webkitpy/common/checkout/changelog.py
new file mode 100644
index 0000000..e93896f
--- /dev/null
+++ b/WebKitTools/Scripts/webkitpy/common/checkout/changelog.py
@@ -0,0 +1,181 @@
+# Copyright (C) 2009, Google Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# * Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following disclaimer
+# in the documentation and/or other materials provided with the
+# distribution.
+# * Neither the name of Google Inc. nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+#
+# WebKit's Python module for parsing and modifying ChangeLog files
+
+import codecs
+import fileinput # inplace file editing for set_reviewer_in_changelog
+import os.path
+import re
+import textwrap
+
+from webkitpy.common.system.deprecated_logging import log
+from webkitpy.common.config.committers import CommitterList
+
+def view_source_url(revision_number):
+ # FIMXE: This doesn't really belong in this file, but we don't have a
+ # better home for it yet.
+ # Maybe eventually a webkit_config.py?
+ return "http://trac.webkit.org/changeset/%s" % revision_number
+
+
+class ChangeLogEntry(object):
+ # e.g. 2009-06-03 Eric Seidel <eric@webkit.org>
+ date_line_regexp = r'^(?P<date>\d{4}-\d{2}-\d{2})\s+(?P<name>.+?)\s+<(?P<email>[^<>]+)>$'
+
+ def __init__(self, contents, committer_list=CommitterList()):
+ self._contents = contents
+ self._committer_list = committer_list
+ self._parse_entry()
+
+ def _parse_entry(self):
+ match = re.match(self.date_line_regexp, self._contents, re.MULTILINE)
+ if not match:
+ log("WARNING: Creating invalid ChangeLogEntry:\n%s" % self._contents)
+
+ # FIXME: group("name") does not seem to be Unicode? Probably due to self._contents not being unicode.
+ self._author_name = match.group("name") if match else None
+ self._author_email = match.group("email") if match else None
+
+ match = re.search("^\s+Reviewed by (?P<reviewer>.*?)[\.,]?\s*$", self._contents, re.MULTILINE) # Discard everything after the first period
+ self._reviewer_text = match.group("reviewer") if match else None
+
+ self._reviewer = self._committer_list.committer_by_name(self._reviewer_text)
+ self._author = self._committer_list.committer_by_email(self._author_email) or self._committer_list.committer_by_name(self._author_name)
+
+ def author_name(self):
+ return self._author_name
+
+ def author_email(self):
+ return self._author_email
+
+ def author(self):
+ return self._author # Might be None
+
+ # FIXME: Eventually we would like to map reviwer names to reviewer objects.
+ # See https://bugs.webkit.org/show_bug.cgi?id=26533
+ def reviewer_text(self):
+ return self._reviewer_text
+
+ def reviewer(self):
+ return self._reviewer # Might be None
+
+ def contents(self):
+ return self._contents
+
+
+# FIXME: Various methods on ChangeLog should move into ChangeLogEntry instead.
+class ChangeLog(object):
+
+ def __init__(self, path):
+ self.path = path
+
+ _changelog_indent = " " * 8
+
+ @staticmethod
+ def parse_latest_entry_from_file(changelog_file):
+ date_line_regexp = re.compile(ChangeLogEntry.date_line_regexp)
+ entry_lines = []
+ # The first line should be a date line.
+ first_line = changelog_file.readline()
+ if not date_line_regexp.match(first_line):
+ return None
+ entry_lines.append(first_line)
+
+ for line in changelog_file:
+ # If we've hit the next entry, return.
+ if date_line_regexp.match(line):
+ # Remove the extra newline at the end
+ return ChangeLogEntry(''.join(entry_lines[:-1]))
+ entry_lines.append(line)
+ return None # We never found a date line!
+
+ def latest_entry(self):
+ # ChangeLog files are always UTF-8, we read them in as such to support Reviewers with unicode in their names.
+ changelog_file = codecs.open(self.path, "r", "utf-8")
+ try:
+ return self.parse_latest_entry_from_file(changelog_file)
+ finally:
+ changelog_file.close()
+
+ # _wrap_line and _wrap_lines exist to work around
+ # http://bugs.python.org/issue1859
+
+ def _wrap_line(self, line):
+ return textwrap.fill(line,
+ width=70,
+ initial_indent=self._changelog_indent,
+ # Don't break urls which may be longer than width.
+ break_long_words=False,
+ subsequent_indent=self._changelog_indent)
+
+ # Workaround as suggested by guido in
+ # http://bugs.python.org/issue1859#msg60040
+
+ def _wrap_lines(self, message):
+ lines = [self._wrap_line(line) for line in message.splitlines()]
+ return "\n".join(lines)
+
+ # This probably does not belong in changelogs.py
+ def _message_for_revert(self, revision, reason, bug_url):
+ message = "Unreviewed, rolling out r%s.\n" % revision
+ message += "%s\n" % view_source_url(revision)
+ if bug_url:
+ message += "%s\n" % bug_url
+ # Add an extra new line after the rollout links, before any reason.
+ message += "\n"
+ if reason:
+ message += "%s\n\n" % reason
+ return self._wrap_lines(message)
+
+ def update_for_revert(self, revision, reason, bug_url=None):
+ reviewed_by_regexp = re.compile(
+ "%sReviewed by NOBODY \(OOPS!\)\." % self._changelog_indent)
+ removing_boilerplate = False
+ # inplace=1 creates a backup file and re-directs stdout to the file
+ for line in fileinput.FileInput(self.path, inplace=1):
+ if reviewed_by_regexp.search(line):
+ message_lines = self._message_for_revert(revision,
+ reason,
+ bug_url)
+ print reviewed_by_regexp.sub(message_lines, line),
+ # Remove all the ChangeLog boilerplate between the Reviewed by
+ # line and the first changed file.
+ removing_boilerplate = True
+ elif removing_boilerplate:
+ if line.find('*') >= 0: # each changed file is preceded by a *
+ removing_boilerplate = False
+
+ if not removing_boilerplate:
+ print line,
+
+ def set_reviewer(self, reviewer):
+ # inplace=1 creates a backup file and re-directs stdout to the file
+ for line in fileinput.FileInput(self.path, inplace=1):
+ # Trailing comma suppresses printing newline
+ print line.replace("NOBODY (OOPS!)", reviewer.encode("utf-8")),
diff --git a/WebKitTools/Scripts/webkitpy/common/checkout/changelog_unittest.py b/WebKitTools/Scripts/webkitpy/common/checkout/changelog_unittest.py
new file mode 100644
index 0000000..9210c9c
--- /dev/null
+++ b/WebKitTools/Scripts/webkitpy/common/checkout/changelog_unittest.py
@@ -0,0 +1,190 @@
+# Copyright (C) 2009 Google Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# * Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following disclaimer
+# in the documentation and/or other materials provided with the
+# distribution.
+# * Neither the name of Google Inc. nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import unittest
+import os
+import tempfile
+
+from StringIO import StringIO
+
+from webkitpy.common.checkout.changelog import *
+
+
+class ChangeLogsTest(unittest.TestCase):
+
+ _example_entry = u'''2009-08-17 Peter Kasting <pkasting@google.com>
+
+ Reviewed by Tor Arne Vestb\xf8.
+
+ https://bugs.webkit.org/show_bug.cgi?id=27323
+ Only add Cygwin to the path when it isn't already there. This avoids
+ causing problems for people who purposefully have non-Cygwin versions of
+ executables like svn in front of the Cygwin ones in their paths.
+
+ * DumpRenderTree/win/DumpRenderTree.vcproj:
+ * DumpRenderTree/win/ImageDiff.vcproj:
+ * DumpRenderTree/win/TestNetscapePlugin/TestNetscapePlugin.vcproj:
+'''
+
+ # More example text than we need. Eventually we need to support parsing this all and write tests for the parsing.
+ _example_changelog = '''2009-08-17 David Kilzer <ddkilzer@apple.com>
+
+ <http://webkit.org/b/28393> check-webkit-style: add check for use of std::max()/std::min() instead of MAX()/MIN()
+
+ Reviewed by David Levin.
+
+ * Scripts/modules/cpp_style.py:
+ (_ERROR_CATEGORIES): Added 'runtime/max_min_macros'.
+ (check_max_min_macros): Added. Returns level 4 error when MAX()
+ and MIN() macros are used in header files and C++ source files.
+ (check_style): Added call to check_max_min_macros().
+ * Scripts/modules/cpp_style_unittest.py: Added unit tests.
+ (test_max_macro): Added.
+ (test_min_macro): Added.
+
+2009-08-16 David Kilzer <ddkilzer@apple.com>
+
+ Backed out r47343 which was mistakenly committed
+
+ * Scripts/bugzilla-tool:
+ * Scripts/modules/scm.py:
+
+2009-06-18 Darin Adler <darin@apple.com>
+
+ Rubber stamped by Mark Rowe.
+
+ * DumpRenderTree/mac/DumpRenderTreeWindow.mm:
+ (-[DumpRenderTreeWindow close]): Resolved crashes seen during regression
+ tests. The close method can be called on a window that's already closed
+ so we can't assert here.
+
+== Rolled over to ChangeLog-2009-06-16 ==
+'''
+
+ def test_latest_entry_parse(self):
+ changelog_contents = "%s\n%s" % (self._example_entry, self._example_changelog)
+ changelog_file = StringIO(changelog_contents)
+ latest_entry = ChangeLog.parse_latest_entry_from_file(changelog_file)
+ self.assertEquals(latest_entry.contents(), self._example_entry)
+ self.assertEquals(latest_entry.author_name(), "Peter Kasting")
+ self.assertEquals(latest_entry.author_email(), "pkasting@google.com")
+ self.assertEquals(latest_entry.reviewer_text(), u"Tor Arne Vestb\xf8")
+ self.assertTrue(latest_entry.reviewer()) # Make sure that our UTF8-based lookup of Tor works.
+
+ @staticmethod
+ def _write_tmp_file_with_contents(contents):
+ (file_descriptor, file_path) = tempfile.mkstemp() # NamedTemporaryFile always deletes the file on close in python < 2.6
+ file = os.fdopen(file_descriptor, 'w')
+ file.write(contents)
+ file.close()
+ return file_path
+
+ @staticmethod
+ def _read_file_contents(file_path):
+ file = open(file_path)
+ contents = file.read()
+ file.close()
+ return contents
+
+ _new_entry_boilerplate = '''2009-08-19 Eric Seidel <eric@webkit.org>
+
+ Reviewed by NOBODY (OOPS!).
+
+ Need a short description and bug URL (OOPS!)
+
+ * Scripts/bugzilla-tool:
+'''
+
+ def test_set_reviewer(self):
+ changelog_contents = "%s\n%s" % (self._new_entry_boilerplate, self._example_changelog)
+ changelog_path = self._write_tmp_file_with_contents(changelog_contents)
+ reviewer_name = 'Test Reviewer'
+ ChangeLog(changelog_path).set_reviewer(reviewer_name)
+ actual_contents = self._read_file_contents(changelog_path)
+ expected_contents = changelog_contents.replace('NOBODY (OOPS!)', reviewer_name)
+ os.remove(changelog_path)
+ self.assertEquals(actual_contents, expected_contents)
+
+ _revert_message = """ Unreviewed, rolling out r12345.
+ http://trac.webkit.org/changeset/12345
+ http://example.com/123
+
+ This is a very long reason which should be long enough so that
+ _message_for_revert will need to wrap it. We'll also include
+ a
+ https://veryveryveryveryverylongbugurl.com/reallylongbugthingy.cgi?bug_id=12354
+ link so that we can make sure we wrap that right too.
+"""
+
+ def test_message_for_revert(self):
+ changelog = ChangeLog("/fake/path")
+ long_reason = "This is a very long reason which should be long enough so that _message_for_revert will need to wrap it. We'll also include a https://veryveryveryveryverylongbugurl.com/reallylongbugthingy.cgi?bug_id=12354 link so that we can make sure we wrap that right too."
+ message = changelog._message_for_revert(12345, long_reason, "http://example.com/123")
+ self.assertEquals(message, self._revert_message)
+
+ _revert_entry_with_bug_url = '''2009-08-19 Eric Seidel <eric@webkit.org>
+
+ Unreviewed, rolling out r12345.
+ http://trac.webkit.org/changeset/12345
+ http://example.com/123
+
+ Reason
+
+ * Scripts/bugzilla-tool:
+'''
+
+ _revert_entry_without_bug_url = '''2009-08-19 Eric Seidel <eric@webkit.org>
+
+ Unreviewed, rolling out r12345.
+ http://trac.webkit.org/changeset/12345
+
+ Reason
+
+ * Scripts/bugzilla-tool:
+'''
+
+ def _assert_update_for_revert_output(self, args, expected_entry):
+ changelog_contents = "%s\n%s" % (self._new_entry_boilerplate, self._example_changelog)
+ changelog_path = self._write_tmp_file_with_contents(changelog_contents)
+ changelog = ChangeLog(changelog_path)
+ changelog.update_for_revert(*args)
+ actual_entry = changelog.latest_entry()
+ os.remove(changelog_path)
+ self.assertEquals(actual_entry.contents(), expected_entry)
+ self.assertEquals(actual_entry.reviewer_text(), None)
+ # These checks could be removed to allow this to work on other entries:
+ self.assertEquals(actual_entry.author_name(), "Eric Seidel")
+ self.assertEquals(actual_entry.author_email(), "eric@webkit.org")
+
+ def test_update_for_revert(self):
+ self._assert_update_for_revert_output([12345, "Reason"], self._revert_entry_without_bug_url)
+ self._assert_update_for_revert_output([12345, "Reason", "http://example.com/123"], self._revert_entry_with_bug_url)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/WebKitTools/Scripts/webkitpy/common/checkout/commitinfo.py b/WebKitTools/Scripts/webkitpy/common/checkout/commitinfo.py
new file mode 100644
index 0000000..7c3315f
--- /dev/null
+++ b/WebKitTools/Scripts/webkitpy/common/checkout/commitinfo.py
@@ -0,0 +1,95 @@
+# Copyright (c) 2010 Google Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# * Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following disclaimer
+# in the documentation and/or other materials provided with the
+# distribution.
+# * Neither the name of Google Inc. nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+#
+# WebKit's python module for holding information on a commit
+
+import StringIO
+
+from webkitpy.common.checkout.changelog import view_source_url
+from webkitpy.common.config.committers import CommitterList
+
+
+class CommitInfo(object):
+ def __init__(self, revision, committer_email, changelog_data, committer_list=CommitterList()):
+ self._revision = revision
+ self._committer_email = committer_email
+ self._bug_id = changelog_data["bug_id"]
+ self._author_name = changelog_data["author_name"]
+ self._author_email = changelog_data["author_email"]
+ self._author = changelog_data["author"]
+ self._reviewer_text = changelog_data["reviewer_text"]
+ self._reviewer = changelog_data["reviewer"]
+
+ # Derived values:
+ self._committer = committer_list.committer_by_email(committer_email)
+
+ def revision(self):
+ return self._revision
+
+ def committer(self):
+ return self._committer # None if committer isn't in committers.py
+
+ def committer_email(self):
+ return self._committer_email
+
+ def bug_id(self):
+ return self._bug_id # May be None
+
+ def author(self):
+ return self._author # May be None
+
+ def author_name(self):
+ return self._author_name
+
+ def author_email(self):
+ return self._author_email
+
+ def reviewer(self):
+ return self._reviewer # May be None
+
+ def reviewer_text(self):
+ return self._reviewer_text # May be None
+
+ def responsible_parties(self):
+ responsible_parties = [
+ self.committer(),
+ self.author(),
+ self.reviewer(),
+ ]
+ return set([party for party in responsible_parties if party]) # Filter out None
+
+ # FIXME: It is slightly lame that this "view" method is on this "model" class (in MVC terms)
+ def blame_string(self, bugs):
+ string = "r%s:\n" % self.revision()
+ string += " %s\n" % view_source_url(self.revision())
+ string += " Bug: %s (%s)\n" % (self.bug_id(), bugs.bug_url_for_bug_id(self.bug_id()))
+ author_line = "\"%s\" <%s>" % (self.author_name(), self.author_email())
+ string += " Author: %s\n" % (self.author() or author_line)
+ string += " Reviewer: %s\n" % (self.reviewer() or self.reviewer_text())
+ string += " Committer: %s" % self.committer()
+ return string
diff --git a/WebKitTools/Scripts/webkitpy/common/checkout/commitinfo_unittest.py b/WebKitTools/Scripts/webkitpy/common/checkout/commitinfo_unittest.py
new file mode 100644
index 0000000..f58e6f1
--- /dev/null
+++ b/WebKitTools/Scripts/webkitpy/common/checkout/commitinfo_unittest.py
@@ -0,0 +1,61 @@
+# Copyright (C) 2010 Google Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# * Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following disclaimer
+# in the documentation and/or other materials provided with the
+# distribution.
+# * Neither the name of Google Inc. nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import unittest
+
+from webkitpy.common.checkout.commitinfo import CommitInfo
+from webkitpy.common.config.committers import CommitterList, Committer, Reviewer
+
+class CommitInfoTest(unittest.TestCase):
+
+ def test_commit_info_creation(self):
+ author = Committer("Author", "author@example.com")
+ committer = Committer("Committer", "committer@example.com")
+ reviewer = Reviewer("Reviewer", "reviewer@example.com")
+ committer_list = CommitterList(committers=[author, committer], reviewers=[reviewer])
+
+ changelog_data = {
+ "bug_id": 1234,
+ "author_name": "Committer",
+ "author_email": "author@example.com",
+ "author": author,
+ "reviewer_text": "Reviewer",
+ "reviewer": reviewer,
+ }
+ commit = CommitInfo(123, "committer@example.com", changelog_data, committer_list)
+
+ self.assertEqual(commit.revision(), 123)
+ self.assertEqual(commit.bug_id(), 1234)
+ self.assertEqual(commit.author_name(), "Committer")
+ self.assertEqual(commit.author_email(), "author@example.com")
+ self.assertEqual(commit.author(), author)
+ self.assertEqual(commit.reviewer_text(), "Reviewer")
+ self.assertEqual(commit.reviewer(), reviewer)
+ self.assertEqual(commit.committer(), committer)
+ self.assertEqual(commit.committer_email(), "committer@example.com")
+ self.assertEqual(commit.responsible_parties(), set([author, committer, reviewer]))
diff --git a/WebKitTools/Scripts/webkitpy/common/checkout/diff_parser.py b/WebKitTools/Scripts/webkitpy/common/checkout/diff_parser.py
new file mode 100644
index 0000000..d8ebae6
--- /dev/null
+++ b/WebKitTools/Scripts/webkitpy/common/checkout/diff_parser.py
@@ -0,0 +1,165 @@
+# Copyright (C) 2009 Google Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# * Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following disclaimer
+# in the documentation and/or other materials provided with the
+# distribution.
+# * Neither the name of Google Inc. nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+"""WebKit's Python module for interacting with patches."""
+
+import logging
+import re
+
+_log = logging.getLogger("webkitpy.common.checkout.diff_parser")
+
+_regexp_compile_cache = {}
+
+
+def match(pattern, string):
+ """Matches the string with the pattern, caching the compiled regexp."""
+ if not pattern in _regexp_compile_cache:
+ _regexp_compile_cache[pattern] = re.compile(pattern)
+ return _regexp_compile_cache[pattern].match(string)
+
+
+def git_diff_to_svn_diff(line):
+ """Converts a git formatted diff line to a svn formatted line.
+
+ Args:
+ line: A string representing a line of the diff.
+ """
+ conversion_patterns = (("^diff --git \w/(.+) \w/(?P<FilePath>.+)", lambda matched: "Index: " + matched.group('FilePath') + "\n"),
+ ("^new file.*", lambda matched: "\n"),
+ ("^index [0-9a-f]{7}\.\.[0-9a-f]{7} [0-9]{6}", lambda matched: "===================================================================\n"),
+ ("^--- \w/(?P<FilePath>.+)", lambda matched: "--- " + matched.group('FilePath') + "\n"),
+ ("^\+\+\+ \w/(?P<FilePath>.+)", lambda matched: "+++ " + matched.group('FilePath') + "\n"))
+
+ for pattern, conversion in conversion_patterns:
+ matched = match(pattern, line)
+ if matched:
+ return conversion(matched)
+ return line
+
+
+def get_diff_converter(first_diff_line):
+ """Gets a converter function of diff lines.
+
+ Args:
+ first_diff_line: The first filename line of a diff file.
+ If this line is git formatted, we'll return a
+ converter from git to SVN.
+ """
+ if match(r"^diff --git \w/", first_diff_line):
+ return git_diff_to_svn_diff
+ return lambda input: input
+
+
+_INITIAL_STATE = 1
+_DECLARED_FILE_PATH = 2
+_PROCESSING_CHUNK = 3
+
+
+class DiffFile:
+ """Contains the information for one file in a patch.
+
+ The field "lines" is a list which contains tuples in this format:
+ (deleted_line_number, new_line_number, line_string)
+ If deleted_line_number is zero, it means this line is newly added.
+ If new_line_number is zero, it means this line is deleted.
+ """
+
+ def __init__(self, filename):
+ self.filename = filename
+ self.lines = []
+
+ def add_new_line(self, line_number, line):
+ self.lines.append((0, line_number, line))
+
+ def add_deleted_line(self, line_number, line):
+ self.lines.append((line_number, 0, line))
+
+ def add_unchanged_line(self, deleted_line_number, new_line_number, line):
+ self.lines.append((deleted_line_number, new_line_number, line))
+
+
+class DiffParser:
+ """A parser for a patch file.
+
+ The field "files" is a dict whose key is the filename and value is
+ a DiffFile object.
+ """
+
+ def __init__(self, diff_input):
+ """Parses a diff.
+
+ Args:
+ diff_input: An iterable object.
+ """
+ state = _INITIAL_STATE
+
+ self.files = {}
+ current_file = None
+ old_diff_line = None
+ new_diff_line = None
+ for line in diff_input:
+ line = line.rstrip("\n")
+ if state == _INITIAL_STATE:
+ transform_line = get_diff_converter(line)
+ line = transform_line(line)
+
+ file_declaration = match(r"^Index: (?P<FilePath>.+)", line)
+ if file_declaration:
+ filename = file_declaration.group('FilePath')
+ current_file = DiffFile(filename)
+ self.files[filename] = current_file
+ state = _DECLARED_FILE_PATH
+ continue
+
+ lines_changed = match(r"^@@ -(?P<OldStartLine>\d+)(,\d+)? \+(?P<NewStartLine>\d+)(,\d+)? @@", line)
+ if lines_changed:
+ if state != _DECLARED_FILE_PATH and state != _PROCESSING_CHUNK:
+ _log.error('Unexpected line change without file path '
+ 'declaration: %r' % line)
+ old_diff_line = int(lines_changed.group('OldStartLine'))
+ new_diff_line = int(lines_changed.group('NewStartLine'))
+ state = _PROCESSING_CHUNK
+ continue
+
+ if state == _PROCESSING_CHUNK:
+ if line.startswith('+'):
+ current_file.add_new_line(new_diff_line, line[1:])
+ new_diff_line += 1
+ elif line.startswith('-'):
+ current_file.add_deleted_line(old_diff_line, line[1:])
+ old_diff_line += 1
+ elif line.startswith(' '):
+ current_file.add_unchanged_line(old_diff_line, new_diff_line, line[1:])
+ old_diff_line += 1
+ new_diff_line += 1
+ elif line == '\\ No newline at end of file':
+ # Nothing to do. We may still have some added lines.
+ pass
+ else:
+ _log.error('Unexpected diff format when parsing a '
+ 'chunk: %r' % line)
diff --git a/WebKitTools/Scripts/webkitpy/common/checkout/diff_parser_unittest.py b/WebKitTools/Scripts/webkitpy/common/checkout/diff_parser_unittest.py
new file mode 100644
index 0000000..7eb0eab
--- /dev/null
+++ b/WebKitTools/Scripts/webkitpy/common/checkout/diff_parser_unittest.py
@@ -0,0 +1,146 @@
+# Copyright (C) 2009 Google Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# * Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following disclaimer
+# in the documentation and/or other materials provided with the
+# distribution.
+# * Neither the name of Google Inc. nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import unittest
+import diff_parser
+import re
+
+
+class DiffParserTest(unittest.TestCase):
+
+ _PATCH = '''diff --git a/WebCore/rendering/style/StyleFlexibleBoxData.h b/WebCore/rendering/style/StyleFlexibleBoxData.h
+index f5d5e74..3b6aa92 100644
+--- a/WebCore/rendering/style/StyleFlexibleBoxData.h
++++ b/WebCore/rendering/style/StyleFlexibleBoxData.h
+@@ -47,7 +47,6 @@ public:
+
+ unsigned align : 3; // EBoxAlignment
+ unsigned pack: 3; // EBoxAlignment
+- unsigned orient: 1; // EBoxOrient
+ unsigned lines : 1; // EBoxLines
+
+ private:
+diff --git a/WebCore/rendering/style/StyleRareInheritedData.cpp b/WebCore/rendering/style/StyleRareInheritedData.cpp
+index ce21720..324929e 100644
+--- a/WebCore/rendering/style/StyleRareInheritedData.cpp
++++ b/WebCore/rendering/style/StyleRareInheritedData.cpp
+@@ -39,6 +39,7 @@ StyleRareInheritedData::StyleRareInheritedData()
+ , textSizeAdjust(RenderStyle::initialTextSizeAdjust())
+ , resize(RenderStyle::initialResize())
+ , userSelect(RenderStyle::initialUserSelect())
++ , boxOrient(RenderStyle::initialBoxOrient())
+ {
+ }
+
+@@ -58,6 +59,7 @@ StyleRareInheritedData::StyleRareInheritedData(const StyleRareInheritedData& o)
+ , textSizeAdjust(o.textSizeAdjust)
+ , resize(o.resize)
+ , userSelect(o.userSelect)
++ , boxOrient(o.boxOrient)
+ {
+ }
+
+@@ -81,7 +83,8 @@ bool StyleRareInheritedData::operator==(const StyleRareInheritedData& o) const
+ && khtmlLineBreak == o.khtmlLineBreak
+ && textSizeAdjust == o.textSizeAdjust
+ && resize == o.resize
+- && userSelect == o.userSelect;
++ && userSelect == o.userSelect
++ && boxOrient == o.boxOrient;
+ }
+
+ bool StyleRareInheritedData::shadowDataEquivalent(const StyleRareInheritedData& o) const
+diff --git a/LayoutTests/platform/mac/fast/flexbox/box-orient-button-expected.checksum b/LayoutTests/platform/mac/fast/flexbox/box-orient-button-expected.checksum
+new file mode 100644
+index 0000000..6db26bd
+--- /dev/null
++++ b/LayoutTests/platform/mac/fast/flexbox/box-orient-button-expected.checksum
+@@ -0,0 +1 @@
++61a373ee739673a9dcd7bac62b9f182e
+\ No newline at end of file
+'''
+
+ def test_diff_parser(self, parser = None):
+ if not parser:
+ parser = diff_parser.DiffParser(self._PATCH.splitlines())
+ self.assertEquals(3, len(parser.files))
+
+ self.assertTrue('WebCore/rendering/style/StyleFlexibleBoxData.h' in parser.files)
+ diff = parser.files['WebCore/rendering/style/StyleFlexibleBoxData.h']
+ self.assertEquals(7, len(diff.lines))
+ # The first two unchaged lines.
+ self.assertEquals((47, 47), diff.lines[0][0:2])
+ self.assertEquals('', diff.lines[0][2])
+ self.assertEquals((48, 48), diff.lines[1][0:2])
+ self.assertEquals(' unsigned align : 3; // EBoxAlignment', diff.lines[1][2])
+ # The deleted line
+ self.assertEquals((50, 0), diff.lines[3][0:2])
+ self.assertEquals(' unsigned orient: 1; // EBoxOrient', diff.lines[3][2])
+
+ # The first file looks OK. Let's check the next, more complicated file.
+ self.assertTrue('WebCore/rendering/style/StyleRareInheritedData.cpp' in parser.files)
+ diff = parser.files['WebCore/rendering/style/StyleRareInheritedData.cpp']
+ # There are 3 chunks.
+ self.assertEquals(7 + 7 + 9, len(diff.lines))
+ # Around an added line.
+ self.assertEquals((60, 61), diff.lines[9][0:2])
+ self.assertEquals((0, 62), diff.lines[10][0:2])
+ self.assertEquals((61, 63), diff.lines[11][0:2])
+ # Look through the last chunk, which contains both add's and delete's.
+ self.assertEquals((81, 83), diff.lines[14][0:2])
+ self.assertEquals((82, 84), diff.lines[15][0:2])
+ self.assertEquals((83, 85), diff.lines[16][0:2])
+ self.assertEquals((84, 0), diff.lines[17][0:2])
+ self.assertEquals((0, 86), diff.lines[18][0:2])
+ self.assertEquals((0, 87), diff.lines[19][0:2])
+ self.assertEquals((85, 88), diff.lines[20][0:2])
+ self.assertEquals((86, 89), diff.lines[21][0:2])
+ self.assertEquals((87, 90), diff.lines[22][0:2])
+
+ # Check if a newly added file is correctly handled.
+ diff = parser.files['LayoutTests/platform/mac/fast/flexbox/box-orient-button-expected.checksum']
+ self.assertEquals(1, len(diff.lines))
+ self.assertEquals((0, 1), diff.lines[0][0:2])
+
+ def test_git_mnemonicprefix(self):
+ p = re.compile(r' ([a|b])/')
+
+ prefixes = [
+ { 'a' : 'i', 'b' : 'w' }, # git-diff (compares the (i)ndex and the (w)ork tree)
+ { 'a' : 'c', 'b' : 'w' }, # git-diff HEAD (compares a (c)ommit and the (w)ork tree)
+ { 'a' : 'c', 'b' : 'i' }, # git diff --cached (compares a (c)ommit and the (i)ndex)
+ { 'a' : 'o', 'b' : 'w' }, # git-diff HEAD:file1 file2 (compares an (o)bject and a (w)ork tree entity)
+ { 'a' : '1', 'b' : '2' }, # git diff --no-index a b (compares two non-git things (1) and (2))
+ ]
+
+ for prefix in prefixes:
+ patch = p.sub(lambda x: " %s/" % prefix[x.group(1)], self._PATCH)
+ self.test_diff_parser(diff_parser.DiffParser(patch.splitlines()))
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/WebKitTools/Scripts/webkitpy/common/checkout/scm.py b/WebKitTools/Scripts/webkitpy/common/checkout/scm.py
new file mode 100644
index 0000000..2704f07
--- /dev/null
+++ b/WebKitTools/Scripts/webkitpy/common/checkout/scm.py
@@ -0,0 +1,616 @@
+# Copyright (c) 2009, Google Inc. All rights reserved.
+# Copyright (c) 2009 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# * Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following disclaimer
+# in the documentation and/or other materials provided with the
+# distribution.
+# * Neither the name of Google Inc. nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+#
+# Python module for interacting with an SCM system (like SVN or Git)
+
+import os
+import re
+
+# FIXME: Instead of using run_command directly, most places in this
+# class would rather use an SCM.run method which automatically set
+# cwd=self.checkout_root.
+from webkitpy.common.system.executive import Executive, run_command, ScriptError
+from webkitpy.common.system.user import User
+from webkitpy.common.system.deprecated_logging import error, log
+
+
+def detect_scm_system(path):
+ if SVN.in_working_directory(path):
+ return SVN(cwd=path)
+
+ if Git.in_working_directory(path):
+ return Git(cwd=path)
+
+ return None
+
+
+def first_non_empty_line_after_index(lines, index=0):
+ first_non_empty_line = index
+ for line in lines[index:]:
+ if re.match("^\s*$", line):
+ first_non_empty_line += 1
+ else:
+ break
+ return first_non_empty_line
+
+
+class CommitMessage:
+ def __init__(self, message):
+ self.message_lines = message[first_non_empty_line_after_index(message, 0):]
+
+ def body(self, lstrip=False):
+ lines = self.message_lines[first_non_empty_line_after_index(self.message_lines, 1):]
+ if lstrip:
+ lines = [line.lstrip() for line in lines]
+ return "\n".join(lines) + "\n"
+
+ def description(self, lstrip=False, strip_url=False):
+ line = self.message_lines[0]
+ if lstrip:
+ line = line.lstrip()
+ if strip_url:
+ line = re.sub("^(\s*)<.+> ", "\1", line)
+ return line
+
+ def message(self):
+ return "\n".join(self.message_lines) + "\n"
+
+
+class CheckoutNeedsUpdate(ScriptError):
+ def __init__(self, script_args, exit_code, output, cwd):
+ ScriptError.__init__(self, script_args=script_args, exit_code=exit_code, output=output, cwd=cwd)
+
+
+def commit_error_handler(error):
+ if re.search("resource out of date", error.output):
+ raise CheckoutNeedsUpdate(script_args=error.script_args, exit_code=error.exit_code, output=error.output, cwd=error.cwd)
+ Executive.default_error_handler(error)
+
+
+# SCM methods are expected to return paths relative to self.checkout_root.
+class SCM:
+ def __init__(self, cwd):
+ self.cwd = cwd
+ self.checkout_root = self.find_checkout_root(self.cwd)
+ self.dryrun = False
+
+ # SCM always returns repository relative path, but sometimes we need
+ # absolute paths to pass to rm, etc.
+ def absolute_path(self, repository_relative_path):
+ return os.path.join(self.checkout_root, repository_relative_path)
+
+ # FIXME: This belongs in Checkout, not SCM.
+ def scripts_directory(self):
+ return os.path.join(self.checkout_root, "WebKitTools", "Scripts")
+
+ # FIXME: This belongs in Checkout, not SCM.
+ def script_path(self, script_name):
+ return os.path.join(self.scripts_directory(), script_name)
+
+ def ensure_clean_working_directory(self, force_clean):
+ if not force_clean and not self.working_directory_is_clean():
+ # FIXME: Shouldn't this use cwd=self.checkout_root?
+ print run_command(self.status_command(), error_handler=Executive.ignore_error)
+ raise ScriptError(message="Working directory has modifications, pass --force-clean or --no-clean to continue.")
+
+ log("Cleaning working directory")
+ self.clean_working_directory()
+
+ def ensure_no_local_commits(self, force):
+ if not self.supports_local_commits():
+ return
+ commits = self.local_commits()
+ if not len(commits):
+ return
+ if not force:
+ error("Working directory has local commits, pass --force-clean to continue.")
+ self.discard_local_commits()
+
+ def run_status_and_extract_filenames(self, status_command, status_regexp):
+ filenames = []
+ # We run with cwd=self.checkout_root so that returned-paths are root-relative.
+ for line in run_command(status_command, cwd=self.checkout_root).splitlines():
+ match = re.search(status_regexp, line)
+ if not match:
+ continue
+ # status = match.group('status')
+ filename = match.group('filename')
+ filenames.append(filename)
+ return filenames
+
+ def strip_r_from_svn_revision(self, svn_revision):
+ match = re.match("^r(?P<svn_revision>\d+)", svn_revision)
+ if (match):
+ return match.group('svn_revision')
+ return svn_revision
+
+ def svn_revision_from_commit_text(self, commit_text):
+ match = re.search(self.commit_success_regexp(), commit_text, re.MULTILINE)
+ return match.group('svn_revision')
+
+ @staticmethod
+ def in_working_directory(path):
+ raise NotImplementedError, "subclasses must implement"
+
+ @staticmethod
+ def find_checkout_root(path):
+ raise NotImplementedError, "subclasses must implement"
+
+ @staticmethod
+ def commit_success_regexp():
+ raise NotImplementedError, "subclasses must implement"
+
+ def working_directory_is_clean(self):
+ raise NotImplementedError, "subclasses must implement"
+
+ def clean_working_directory(self):
+ raise NotImplementedError, "subclasses must implement"
+
+ def status_command(self):
+ raise NotImplementedError, "subclasses must implement"
+
+ def add(self, path):
+ raise NotImplementedError, "subclasses must implement"
+
+ def changed_files(self):
+ raise NotImplementedError, "subclasses must implement"
+
+ def changed_files_for_revision(self):
+ raise NotImplementedError, "subclasses must implement"
+
+ def added_files(self):
+ raise NotImplementedError, "subclasses must implement"
+
+ def conflicted_files(self):
+ raise NotImplementedError, "subclasses must implement"
+
+ def display_name(self):
+ raise NotImplementedError, "subclasses must implement"
+
+ def create_patch(self):
+ raise NotImplementedError, "subclasses must implement"
+
+ def committer_email_for_revision(self, revision):
+ raise NotImplementedError, "subclasses must implement"
+
+ def contents_at_revision(self, path, revision):
+ raise NotImplementedError, "subclasses must implement"
+
+ def diff_for_revision(self, revision):
+ raise NotImplementedError, "subclasses must implement"
+
+ def apply_reverse_diff(self, revision):
+ raise NotImplementedError, "subclasses must implement"
+
+ def revert_files(self, file_paths):
+ raise NotImplementedError, "subclasses must implement"
+
+ def commit_with_message(self, message, username=None):
+ raise NotImplementedError, "subclasses must implement"
+
+ def svn_commit_log(self, svn_revision):
+ raise NotImplementedError, "subclasses must implement"
+
+ def last_svn_commit_log(self):
+ raise NotImplementedError, "subclasses must implement"
+
+ # Subclasses must indicate if they support local commits,
+ # but the SCM baseclass will only call local_commits methods when this is true.
+ @staticmethod
+ def supports_local_commits():
+ raise NotImplementedError, "subclasses must implement"
+
+ def svn_merge_base():
+ raise NotImplementedError, "subclasses must implement"
+
+ def create_patch_from_local_commit(self, commit_id):
+ error("Your source control manager does not support creating a patch from a local commit.")
+
+ def create_patch_since_local_commit(self, commit_id):
+ error("Your source control manager does not support creating a patch from a local commit.")
+
+ def commit_locally_with_message(self, message):
+ error("Your source control manager does not support local commits.")
+
+ def discard_local_commits(self):
+ pass
+
+ def local_commits(self):
+ return []
+
+
+class SVN(SCM):
+ # FIXME: We should move these values to a WebKit-specific config. file.
+ svn_server_host = "svn.webkit.org"
+ svn_server_realm = "<http://svn.webkit.org:80> Mac OS Forge"
+
+ def __init__(self, cwd):
+ SCM.__init__(self, cwd)
+ self.cached_version = None
+
+ @staticmethod
+ def in_working_directory(path):
+ return os.path.isdir(os.path.join(path, '.svn'))
+
+ @classmethod
+ def find_uuid(cls, path):
+ if not cls.in_working_directory(path):
+ return None
+ return cls.value_from_svn_info(path, 'Repository UUID')
+
+ @classmethod
+ def value_from_svn_info(cls, path, field_name):
+ svn_info_args = ['svn', 'info', path]
+ info_output = run_command(svn_info_args).rstrip()
+ match = re.search("^%s: (?P<value>.+)$" % field_name, info_output, re.MULTILINE)
+ if not match:
+ raise ScriptError(script_args=svn_info_args, message='svn info did not contain a %s.' % field_name)
+ return match.group('value')
+
+ @staticmethod
+ def find_checkout_root(path):
+ uuid = SVN.find_uuid(path)
+ # If |path| is not in a working directory, we're supposed to return |path|.
+ if not uuid:
+ return path
+ # Search up the directory hierarchy until we find a different UUID.
+ last_path = None
+ while True:
+ if uuid != SVN.find_uuid(path):
+ return last_path
+ last_path = path
+ (path, last_component) = os.path.split(path)
+ if last_path == path:
+ return None
+
+ @staticmethod
+ def commit_success_regexp():
+ return "^Committed revision (?P<svn_revision>\d+)\.$"
+
+ def has_authorization_for_realm(self, realm=svn_server_realm, home_directory=os.getenv("HOME")):
+ # Assumes find and grep are installed.
+ if not os.path.isdir(os.path.join(home_directory, ".subversion")):
+ return False
+ find_args = ["find", ".subversion", "-type", "f", "-exec", "grep", "-q", realm, "{}", ";", "-print"];
+ find_output = run_command(find_args, cwd=home_directory, error_handler=Executive.ignore_error).rstrip()
+ return find_output and os.path.isfile(os.path.join(home_directory, find_output))
+
+ def svn_version(self):
+ if not self.cached_version:
+ self.cached_version = run_command(['svn', '--version', '--quiet'])
+
+ return self.cached_version
+
+ def working_directory_is_clean(self):
+ return run_command(["svn", "diff"], cwd=self.checkout_root) == ""
+
+ def clean_working_directory(self):
+ # svn revert -R is not as awesome as git reset --hard.
+ # It will leave added files around, causing later svn update
+ # calls to fail on the bots. We make this mirror git reset --hard
+ # by deleting any added files as well.
+ added_files = reversed(sorted(self.added_files()))
+ # added_files() returns directories for SVN, we walk the files in reverse path
+ # length order so that we remove files before we try to remove the directories.
+ run_command(["svn", "revert", "-R", "."], cwd=self.checkout_root)
+ for path in added_files:
+ # This is robust against cwd != self.checkout_root
+ absolute_path = self.absolute_path(path)
+ # Completely lame that there is no easy way to remove both types with one call.
+ if os.path.isdir(path):
+ os.rmdir(absolute_path)
+ else:
+ os.remove(absolute_path)
+
+ def status_command(self):
+ return ['svn', 'status']
+
+ def _status_regexp(self, expected_types):
+ field_count = 6 if self.svn_version() > "1.6" else 5
+ return "^(?P<status>[%s]).{%s} (?P<filename>.+)$" % (expected_types, field_count)
+
+ def add(self, path):
+ # path is assumed to be cwd relative?
+ run_command(["svn", "add", path])
+
+ def changed_files(self):
+ return self.run_status_and_extract_filenames(self.status_command(), self._status_regexp("ACDMR"))
+
+ def changed_files_for_revision(self, revision):
+ # As far as I can tell svn diff --summarize output looks just like svn status output.
+ status_command = ["svn", "diff", "--summarize", "-c", str(revision)]
+ return self.run_status_and_extract_filenames(status_command, self._status_regexp("ACDMR"))
+
+ def conflicted_files(self):
+ return self.run_status_and_extract_filenames(self.status_command(), self._status_regexp("C"))
+
+ def added_files(self):
+ return self.run_status_and_extract_filenames(self.status_command(), self._status_regexp("A"))
+
+ @staticmethod
+ def supports_local_commits():
+ return False
+
+ def display_name(self):
+ return "svn"
+
+ def create_patch(self):
+ return run_command(self.script_path("svn-create-patch"), cwd=self.checkout_root, return_stderr=False)
+
+ def committer_email_for_revision(self, revision):
+ return run_command(["svn", "propget", "svn:author", "--revprop", "-r", str(revision)]).rstrip()
+
+ def contents_at_revision(self, path, revision):
+ remote_path = "%s/%s" % (self._repository_url(), path)
+ return run_command(["svn", "cat", "-r", str(revision), remote_path])
+
+ def diff_for_revision(self, revision):
+ # FIXME: This should probably use cwd=self.checkout_root
+ return run_command(['svn', 'diff', '-c', str(revision)])
+
+ def _repository_url(self):
+ return self.value_from_svn_info(self.checkout_root, 'URL')
+
+ def apply_reverse_diff(self, revision):
+ # '-c -revision' applies the inverse diff of 'revision'
+ svn_merge_args = ['svn', 'merge', '--non-interactive', '-c', '-%s' % revision, self._repository_url()]
+ log("WARNING: svn merge has been known to take more than 10 minutes to complete. It is recommended you use git for rollouts.")
+ log("Running '%s'" % " ".join(svn_merge_args))
+ # FIXME: Should this use cwd=self.checkout_root?
+ run_command(svn_merge_args)
+
+ def revert_files(self, file_paths):
+ # FIXME: This should probably use cwd=self.checkout_root.
+ run_command(['svn', 'revert'] + file_paths)
+
+ def commit_with_message(self, message, username=None):
+ if self.dryrun:
+ # Return a string which looks like a commit so that things which parse this output will succeed.
+ return "Dry run, no commit.\nCommitted revision 0."
+ svn_commit_args = ["svn", "commit"]
+ if not username and not self.has_authorization_for_realm():
+ username = User.prompt("%s login: " % self.svn_server_host, repeat=5)
+ if not username:
+ raise Exception("You need to specify the username on %s to perform the commit as." % self.svn_server_host)
+ if username:
+ svn_commit_args.extend(["--username", username])
+ svn_commit_args.extend(["-m", message])
+ # FIXME: Should this use cwd=self.checkout_root?
+ return run_command(svn_commit_args, error_handler=commit_error_handler)
+
+ def svn_commit_log(self, svn_revision):
+ svn_revision = self.strip_r_from_svn_revision(str(svn_revision))
+ return run_command(['svn', 'log', '--non-interactive', '--revision', svn_revision]);
+
+ def last_svn_commit_log(self):
+ # BASE is the checkout revision, HEAD is the remote repository revision
+ # http://svnbook.red-bean.com/en/1.0/ch03s03.html
+ return self.svn_commit_log('BASE')
+
+# All git-specific logic should go here.
+class Git(SCM):
+ def __init__(self, cwd):
+ SCM.__init__(self, cwd)
+
+ @classmethod
+ def in_working_directory(cls, path):
+ return run_command(['git', 'rev-parse', '--is-inside-work-tree'], cwd=path, error_handler=Executive.ignore_error).rstrip() == "true"
+
+ @classmethod
+ def find_checkout_root(cls, path):
+ # "git rev-parse --show-cdup" would be another way to get to the root
+ (checkout_root, dot_git) = os.path.split(run_command(['git', 'rev-parse', '--git-dir'], cwd=path))
+ # If we were using 2.6 # checkout_root = os.path.relpath(checkout_root, path)
+ if not os.path.isabs(checkout_root): # Sometimes git returns relative paths
+ checkout_root = os.path.join(path, checkout_root)
+ return checkout_root
+
+ @classmethod
+ def read_git_config(cls, key):
+ # FIXME: This should probably use cwd=self.checkout_root.
+ return run_command(["git", "config", key],
+ error_handler=Executive.ignore_error).rstrip('\n')
+
+ @staticmethod
+ def commit_success_regexp():
+ return "^Committed r(?P<svn_revision>\d+)$"
+
+ def discard_local_commits(self):
+ # FIXME: This should probably use cwd=self.checkout_root
+ run_command(['git', 'reset', '--hard', self.svn_branch_name()])
+
+ def local_commits(self):
+ # FIXME: This should probably use cwd=self.checkout_root
+ return run_command(['git', 'log', '--pretty=oneline', 'HEAD...' + self.svn_branch_name()]).splitlines()
+
+ def rebase_in_progress(self):
+ return os.path.exists(os.path.join(self.checkout_root, '.git/rebase-apply'))
+
+ def working_directory_is_clean(self):
+ # FIXME: This should probably use cwd=self.checkout_root
+ return run_command(['git', 'diff', 'HEAD', '--name-only']) == ""
+
+ def clean_working_directory(self):
+ # FIXME: These should probably use cwd=self.checkout_root.
+ # Could run git clean here too, but that wouldn't match working_directory_is_clean
+ run_command(['git', 'reset', '--hard', 'HEAD'])
+ # Aborting rebase even though this does not match working_directory_is_clean
+ if self.rebase_in_progress():
+ run_command(['git', 'rebase', '--abort'])
+
+ def status_command(self):
+ # git status returns non-zero when there are changes, so we use git diff name --name-status HEAD instead.
+ return ["git", "diff", "--name-status", "HEAD"]
+
+ def _status_regexp(self, expected_types):
+ return '^(?P<status>[%s])\t(?P<filename>.+)$' % expected_types
+
+ def add(self, path):
+ # path is assumed to be cwd relative?
+ run_command(["git", "add", path])
+
+ def changed_files(self):
+ status_command = ['git', 'diff', '-r', '--name-status', '-C', '-M', 'HEAD']
+ return self.run_status_and_extract_filenames(status_command, self._status_regexp("ADM"))
+
+ def _changes_files_for_commit(self, git_commit):
+ # --pretty="format:" makes git show not print the commit log header,
+ changed_files = run_command(["git", "show", "--pretty=format:", "--name-only", git_commit]).splitlines()
+ # instead it just prints a blank line at the top, so we skip the blank line:
+ return changed_files[1:]
+
+ def changed_files_for_revision(self, revision):
+ commit_id = self.git_commit_from_svn_revision(revision)
+ return self._changes_files_for_commit(commit_id)
+
+ def conflicted_files(self):
+ status_command = ['git', 'diff', '--name-status', '-C', '-M', '--diff-filter=U']
+ return self.run_status_and_extract_filenames(status_command, self._status_regexp("U"))
+
+ def added_files(self):
+ return self.run_status_and_extract_filenames(self.status_command(), self._status_regexp("A"))
+
+ @staticmethod
+ def supports_local_commits():
+ return True
+
+ def display_name(self):
+ return "git"
+
+ def create_patch(self):
+ # FIXME: This should probably use cwd=self.checkout_root
+ return run_command(['git', 'diff', '--binary', 'HEAD'])
+
+ @classmethod
+ def git_commit_from_svn_revision(cls, revision):
+ # FIXME: This should probably use cwd=self.checkout_root
+ git_commit = run_command(['git', 'svn', 'find-rev', 'r%s' % revision]).rstrip()
+ # git svn find-rev always exits 0, even when the revision is not found.
+ if not git_commit:
+ raise ScriptError(message='Failed to find git commit for revision %s, your checkout likely needs an update.' % revision)
+ return git_commit
+
+ def contents_at_revision(self, path, revision):
+ return run_command(["git", "show", "%s:%s" % (self.git_commit_from_svn_revision(revision), path)])
+
+ def diff_for_revision(self, revision):
+ git_commit = self.git_commit_from_svn_revision(revision)
+ return self.create_patch_from_local_commit(git_commit)
+
+ def committer_email_for_revision(self, revision):
+ git_commit = self.git_commit_from_svn_revision(revision)
+ committer_email = run_command(["git", "log", "-1", "--pretty=format:%ce", git_commit])
+ # Git adds an extra @repository_hash to the end of every committer email, remove it:
+ return committer_email.rsplit("@", 1)[0]
+
+ def apply_reverse_diff(self, revision):
+ # Assume the revision is an svn revision.
+ git_commit = self.git_commit_from_svn_revision(revision)
+ # I think this will always fail due to ChangeLogs.
+ run_command(['git', 'revert', '--no-commit', git_commit], error_handler=Executive.ignore_error)
+
+ def revert_files(self, file_paths):
+ run_command(['git', 'checkout', 'HEAD'] + file_paths)
+
+ def commit_with_message(self, message, username=None):
+ # Username is ignored during Git commits.
+ self.commit_locally_with_message(message)
+ return self.push_local_commits_to_server()
+
+ def svn_commit_log(self, svn_revision):
+ svn_revision = self.strip_r_from_svn_revision(svn_revision)
+ return run_command(['git', 'svn', 'log', '-r', svn_revision])
+
+ def last_svn_commit_log(self):
+ return run_command(['git', 'svn', 'log', '--limit=1'])
+
+ # Git-specific methods:
+
+ def delete_branch(self, branch):
+ if run_command(['git', 'show-ref', '--quiet', '--verify', 'refs/heads/' + branch], return_exit_code=True) == 0:
+ run_command(['git', 'branch', '-D', branch])
+
+ def svn_merge_base(self):
+ return run_command(['git', 'merge-base', self.svn_branch_name(), 'HEAD']).strip()
+
+ def svn_branch_name(self):
+ return Git.read_git_config('svn-remote.svn.fetch').split(':')[1]
+
+ def create_patch_from_local_commit(self, commit_id):
+ return run_command(['git', 'diff', '--binary', commit_id + "^.." + commit_id])
+
+ def create_patch_since_local_commit(self, commit_id):
+ return run_command(['git', 'diff', '--binary', commit_id])
+
+ def commit_locally_with_message(self, message):
+ run_command(['git', 'commit', '--all', '-F', '-'], input=message)
+
+ def push_local_commits_to_server(self):
+ dcommit_command = ['git', 'svn', 'dcommit']
+ if self.dryrun:
+ dcommit_command.append('--dry-run')
+ output = run_command(dcommit_command, error_handler=commit_error_handler)
+ # Return a string which looks like a commit so that things which parse this output will succeed.
+ if self.dryrun:
+ output += "\nCommitted r0"
+ return output
+
+ # This function supports the following argument formats:
+ # no args : rev-list trunk..HEAD
+ # A..B : rev-list A..B
+ # A...B : error!
+ # A B : [A, B] (different from git diff, which would use "rev-list A..B")
+ def commit_ids_from_commitish_arguments(self, args):
+ if not len(args):
+ args.append('%s..HEAD' % self.svn_branch_name())
+
+ commit_ids = []
+ for commitish in args:
+ if '...' in commitish:
+ raise ScriptError(message="'...' is not supported (found in '%s'). Did you mean '..'?" % commitish)
+ elif '..' in commitish:
+ commit_ids += reversed(run_command(['git', 'rev-list', commitish]).splitlines())
+ else:
+ # Turn single commits or branch or tag names into commit ids.
+ commit_ids += run_command(['git', 'rev-parse', '--revs-only', commitish]).splitlines()
+ return commit_ids
+
+ def commit_message_for_local_commit(self, commit_id):
+ commit_lines = run_command(['git', 'cat-file', 'commit', commit_id]).splitlines()
+
+ # Skip the git headers.
+ first_line_after_headers = 0
+ for line in commit_lines:
+ first_line_after_headers += 1
+ if line == "":
+ break
+ return CommitMessage(commit_lines[first_line_after_headers:])
+
+ def files_changed_summary_for_commit(self, commit_id):
+ return run_command(['git', 'diff-tree', '--shortstat', '--no-commit-id', commit_id])
diff --git a/WebKitTools/Scripts/webkitpy/common/checkout/scm_unittest.py b/WebKitTools/Scripts/webkitpy/common/checkout/scm_unittest.py
new file mode 100644
index 0000000..c0a64d4
--- /dev/null
+++ b/WebKitTools/Scripts/webkitpy/common/checkout/scm_unittest.py
@@ -0,0 +1,783 @@
+# Copyright (C) 2009 Google Inc. All rights reserved.
+# Copyright (C) 2009 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# * Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following disclaimer
+# in the documentation and/or other materials provided with the
+# distribution.
+# * Neither the name of Google Inc. nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import base64
+import getpass
+import os
+import os.path
+import re
+import stat
+import subprocess
+import tempfile
+import unittest
+import urllib
+
+from datetime import date
+from webkitpy.common.checkout.api import Checkout
+from webkitpy.common.checkout.scm import detect_scm_system, SCM, SVN, CheckoutNeedsUpdate, commit_error_handler
+from webkitpy.common.config.committers import Committer # FIXME: This should not be needed
+from webkitpy.common.net.bugzilla import Attachment # FIXME: This should not be needed
+from webkitpy.common.system.executive import Executive, run_command, ScriptError
+
+# Eventually we will want to write tests which work for both scms. (like update_webkit, changed_files, etc.)
+# Perhaps through some SCMTest base-class which both SVNTest and GitTest inherit from.
+
+# FIXME: This should be unified into one of the executive.py commands!
+def run_silent(args, cwd=None):
+ process = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=cwd)
+ process.communicate() # ignore output
+ exit_code = process.wait()
+ if exit_code:
+ raise ScriptError('Failed to run "%s" exit_code: %d cwd: %s' % (args, exit_code, cwd))
+
+def write_into_file_at_path(file_path, contents):
+ file = open(file_path, 'w')
+ file.write(contents)
+ file.close()
+
+def read_from_path(file_path):
+ file = open(file_path, 'r')
+ contents = file.read()
+ file.close()
+ return contents
+
+# Exists to share svn repository creation code between the git and svn tests
+class SVNTestRepository:
+ @classmethod
+ def _svn_add(cls, path):
+ run_command(["svn", "add", path])
+
+ @classmethod
+ def _svn_commit(cls, message):
+ run_command(["svn", "commit", "--quiet", "--message", message])
+
+ @classmethod
+ def _setup_test_commits(cls, test_object):
+ # Add some test commits
+ os.chdir(test_object.svn_checkout_path)
+
+ write_into_file_at_path("test_file", "test1")
+ cls._svn_add("test_file")
+ cls._svn_commit("initial commit")
+
+ write_into_file_at_path("test_file", "test1test2")
+ # This used to be the last commit, but doing so broke
+ # GitTest.test_apply_git_patch which use the inverse diff of the last commit.
+ # svn-apply fails to remove directories in Git, see:
+ # https://bugs.webkit.org/show_bug.cgi?id=34871
+ os.mkdir("test_dir")
+ # Slash should always be the right path separator since we use cygwin on Windows.
+ test_file3_path = "test_dir/test_file3"
+ write_into_file_at_path(test_file3_path, "third file")
+ cls._svn_add("test_dir")
+ cls._svn_commit("second commit")
+
+ write_into_file_at_path("test_file", "test1test2test3\n")
+ write_into_file_at_path("test_file2", "second file")
+ cls._svn_add("test_file2")
+ cls._svn_commit("third commit")
+
+ write_into_file_at_path("test_file", "test1test2test3\ntest4\n")
+ cls._svn_commit("fourth commit")
+
+ # svn does not seem to update after commit as I would expect.
+ run_command(['svn', 'update'])
+
+ @classmethod
+ def setup(cls, test_object):
+ # Create an test SVN repository
+ test_object.svn_repo_path = tempfile.mkdtemp(suffix="svn_test_repo")
+ test_object.svn_repo_url = "file://%s" % test_object.svn_repo_path # Not sure this will work on windows
+ # git svn complains if we don't pass --pre-1.5-compatible, not sure why:
+ # Expected FS format '2'; found format '3' at /usr/local/libexec/git-core//git-svn line 1477
+ run_command(['svnadmin', 'create', '--pre-1.5-compatible', test_object.svn_repo_path])
+
+ # Create a test svn checkout
+ test_object.svn_checkout_path = tempfile.mkdtemp(suffix="svn_test_checkout")
+ run_command(['svn', 'checkout', '--quiet', test_object.svn_repo_url, test_object.svn_checkout_path])
+
+ cls._setup_test_commits(test_object)
+
+ @classmethod
+ def tear_down(cls, test_object):
+ run_command(['rm', '-rf', test_object.svn_repo_path])
+ run_command(['rm', '-rf', test_object.svn_checkout_path])
+
+ # Now that we've deleted the checkout paths, cwddir may be invalid
+ # Change back to a valid directory so that later calls to os.getcwd() do not fail.
+ os.chdir(detect_scm_system(os.path.dirname(__file__)).checkout_root)
+
+# For testing the SCM baseclass directly.
+class SCMClassTests(unittest.TestCase):
+ def setUp(self):
+ self.dev_null = open(os.devnull, "w") # Used to make our Popen calls quiet.
+
+ def tearDown(self):
+ self.dev_null.close()
+
+ def test_run_command_with_pipe(self):
+ input_process = subprocess.Popen(['echo', 'foo\nbar'], stdout=subprocess.PIPE, stderr=self.dev_null)
+ self.assertEqual(run_command(['grep', 'bar'], input=input_process.stdout), "bar\n")
+
+ # Test the non-pipe case too:
+ self.assertEqual(run_command(['grep', 'bar'], input="foo\nbar"), "bar\n")
+
+ command_returns_non_zero = ['/bin/sh', '--invalid-option']
+ # Test when the input pipe process fails.
+ input_process = subprocess.Popen(command_returns_non_zero, stdout=subprocess.PIPE, stderr=self.dev_null)
+ self.assertTrue(input_process.poll() != 0)
+ self.assertRaises(ScriptError, run_command, ['grep', 'bar'], input=input_process.stdout)
+
+ # Test when the run_command process fails.
+ input_process = subprocess.Popen(['echo', 'foo\nbar'], stdout=subprocess.PIPE, stderr=self.dev_null) # grep shows usage and calls exit(2) when called w/o arguments.
+ self.assertRaises(ScriptError, run_command, command_returns_non_zero, input=input_process.stdout)
+
+ def test_error_handlers(self):
+ git_failure_message="Merge conflict during commit: Your file or directory 'WebCore/ChangeLog' is probably out-of-date: resource out of date; try updating at /usr/local/libexec/git-core//git-svn line 469"
+ svn_failure_message="""svn: Commit failed (details follow):
+svn: File or directory 'ChangeLog' is out of date; try updating
+svn: resource out of date; try updating
+"""
+ command_does_not_exist = ['does_not_exist', 'invalid_option']
+ self.assertRaises(OSError, run_command, command_does_not_exist)
+ self.assertRaises(OSError, run_command, command_does_not_exist, error_handler=Executive.ignore_error)
+
+ command_returns_non_zero = ['/bin/sh', '--invalid-option']
+ self.assertRaises(ScriptError, run_command, command_returns_non_zero)
+ # Check if returns error text:
+ self.assertTrue(run_command(command_returns_non_zero, error_handler=Executive.ignore_error))
+
+ self.assertRaises(CheckoutNeedsUpdate, commit_error_handler, ScriptError(output=git_failure_message))
+ self.assertRaises(CheckoutNeedsUpdate, commit_error_handler, ScriptError(output=svn_failure_message))
+ self.assertRaises(ScriptError, commit_error_handler, ScriptError(output='blah blah blah'))
+
+
+# GitTest and SVNTest inherit from this so any test_ methods here will be run once for this class and then once for each subclass.
+class SCMTest(unittest.TestCase):
+ def _create_patch(self, patch_contents):
+ patch_path = os.path.join(self.svn_checkout_path, 'patch.diff')
+ write_into_file_at_path(patch_path, patch_contents)
+ patch = {}
+ patch['bug_id'] = '12345'
+ patch['url'] = 'file://%s' % urllib.pathname2url(patch_path)
+
+ attachment = Attachment(patch, None) # FIXME: This is a hack, scm.py shouldn't be fetching attachment data.
+ joe_cool = Committer(name="Joe Cool", email_or_emails=None)
+ attachment._reviewer = joe_cool
+
+ return attachment
+
+ def _setup_webkittools_scripts_symlink(self, local_scm):
+ webkit_scm = detect_scm_system(os.path.dirname(os.path.abspath(__file__)))
+ webkit_scripts_directory = webkit_scm.scripts_directory()
+ local_scripts_directory = local_scm.scripts_directory()
+ os.mkdir(os.path.dirname(local_scripts_directory))
+ os.symlink(webkit_scripts_directory, local_scripts_directory)
+
+ # Tests which both GitTest and SVNTest should run.
+ # FIXME: There must be a simpler way to add these w/o adding a wrapper method to both subclasses
+ def _shared_test_commit_with_message(self, username="dbates@webkit.org"):
+ write_into_file_at_path('test_file', 'more test content')
+ commit_text = self.scm.commit_with_message("another test commit", username)
+ self.assertEqual(self.scm.svn_revision_from_commit_text(commit_text), '5')
+
+ self.scm.dryrun = True
+ write_into_file_at_path('test_file', 'still more test content')
+ commit_text = self.scm.commit_with_message("yet another test commit", username)
+ self.assertEqual(self.scm.svn_revision_from_commit_text(commit_text), '0')
+
+ def _shared_test_changed_files(self):
+ write_into_file_at_path("test_file", "changed content")
+ self.assertEqual(self.scm.changed_files(), ["test_file"])
+ write_into_file_at_path("test_dir/test_file3", "new stuff")
+ self.assertEqual(self.scm.changed_files(), ["test_dir/test_file3", "test_file"])
+ old_cwd = os.getcwd()
+ os.chdir("test_dir")
+ # Validate that changed_files does not change with our cwd, see bug 37015.
+ self.assertEqual(self.scm.changed_files(), ["test_dir/test_file3", "test_file"])
+ os.chdir(old_cwd)
+
+ def _shared_test_added_files(self):
+ write_into_file_at_path("test_file", "changed content")
+ self.assertEqual(self.scm.added_files(), [])
+
+ write_into_file_at_path("added_file", "new stuff")
+ self.scm.add("added_file")
+
+ os.mkdir("added_dir")
+ write_into_file_at_path("added_dir/added_file2", "new stuff")
+ self.scm.add("added_dir")
+
+ # SVN reports directory changes, Git does not.
+ added_files = self.scm.added_files()
+ if "added_dir" in added_files:
+ added_files.remove("added_dir")
+ self.assertEqual(added_files, ["added_dir/added_file2", "added_file"])
+
+ # Test also to make sure clean_working_directory removes added files
+ self.scm.clean_working_directory()
+ self.assertEqual(self.scm.added_files(), [])
+ self.assertFalse(os.path.exists("added_file"))
+ self.assertFalse(os.path.exists("added_dir"))
+
+ def _shared_test_changed_files_for_revision(self):
+ # SVN reports directory changes, Git does not.
+ changed_files = self.scm.changed_files_for_revision(2)
+ if "test_dir" in changed_files:
+ changed_files.remove("test_dir")
+ self.assertEqual(changed_files, ["test_dir/test_file3", "test_file"])
+ self.assertEqual(sorted(self.scm.changed_files_for_revision(3)), sorted(["test_file", "test_file2"])) # Git and SVN return different orders.
+ self.assertEqual(self.scm.changed_files_for_revision(4), ["test_file"])
+
+ def _shared_test_contents_at_revision(self):
+ self.assertEqual(self.scm.contents_at_revision("test_file", 2), "test1test2")
+ self.assertEqual(self.scm.contents_at_revision("test_file", 3), "test1test2test3\n")
+ self.assertEqual(self.scm.contents_at_revision("test_file", 4), "test1test2test3\ntest4\n")
+
+ self.assertEqual(self.scm.contents_at_revision("test_file2", 3), "second file")
+ # Files which don't exist:
+ # Currently we raise instead of returning None because detecting the difference between
+ # "file not found" and any other error seems impossible with svn (git seems to expose such through the return code).
+ self.assertRaises(ScriptError, self.scm.contents_at_revision, "test_file2", 2)
+ self.assertRaises(ScriptError, self.scm.contents_at_revision, "does_not_exist", 2)
+
+ def _shared_test_committer_email_for_revision(self):
+ self.assertEqual(self.scm.committer_email_for_revision(2), getpass.getuser()) # Committer "email" will be the current user
+
+ def _shared_test_reverse_diff(self):
+ self._setup_webkittools_scripts_symlink(self.scm) # Git's apply_reverse_diff uses resolve-ChangeLogs
+ # Only test the simple case, as any other will end up with conflict markers.
+ self.scm.apply_reverse_diff('4')
+ self.assertEqual(read_from_path('test_file'), "test1test2test3\n")
+
+ def _shared_test_diff_for_revision(self):
+ # Patch formats are slightly different between svn and git, so just regexp for things we know should be there.
+ r3_patch = self.scm.diff_for_revision(3)
+ self.assertTrue(re.search('test3', r3_patch))
+ self.assertFalse(re.search('test4', r3_patch))
+ self.assertTrue(re.search('test2', r3_patch))
+ self.assertTrue(re.search('test2', self.scm.diff_for_revision(2)))
+
+ def _shared_test_svn_apply_git_patch(self):
+ self._setup_webkittools_scripts_symlink(self.scm)
+ git_binary_addition = """diff --git a/fizzbuzz7.gif b/fizzbuzz7.gif
+new file mode 100644
+index 0000000000000000000000000000000000000000..64a9532e7794fcd791f6f12157406d90
+60151690
+GIT binary patch
+literal 512
+zcmZ?wbhEHbRAx|MU|?iW{Kxc~?KofD;ckY;H+&5HnHl!!GQMD7h+sU{_)e9f^V3c?
+zhJP##HdZC#4K}7F68@!1jfWQg2daCm-gs#3|JREDT>c+pG4L<_2;w##WMO#ysPPap
+zLqpAf1OE938xAsSp4!5f-o><?VKe(#0jEcwfHGF4%M1^kRs14oVBp2ZEL{E1N<-zJ
+zsfLmOtKta;2_;2c#^S1-8cf<nb!QnGl>c!Xe6RXvrEtAWBvSDTgTO1j3vA31Puw!A
+zs(87q)j_mVDTqBo-P+03-P5mHCEnJ+x}YdCuS7#bCCyePUe(ynK+|4b-3qK)T?Z&)
+zYG+`tl4h?GZv_$t82}X4*DTE|$;{DEiPyF@)U-1+FaX++T9H{&%cag`W1|zVP@`%b
+zqiSkp6{BTpWTkCr!=<C6Q=?#~R8^JfrliAF6Q^gV9Iup8RqCXqqhqC`qsyhk<-nlB
+z00f{QZvfK&|Nm#oZ0TQl`Yr$BIa6A@16O26ud7H<QM=xl`toLKnz-3h@9c9q&wm|X
+z{89I|WPyD!*M?gv?q`;L=2YFeXrJQNti4?}s!zFo=5CzeBxC69xA<zrjP<wUcCRh4
+ptUl-ZG<%a~#LwkIWv&q!KSCH7tQ8cJDiw+|GV?MN)RjY50RTb-xvT&H
+
+literal 0
+HcmV?d00001
+
+"""
+ self.checkout.apply_patch(self._create_patch(git_binary_addition))
+ added = read_from_path('fizzbuzz7.gif')
+ self.assertEqual(512, len(added))
+ self.assertTrue(added.startswith('GIF89a'))
+ self.assertTrue('fizzbuzz7.gif' in self.scm.changed_files())
+
+ # The file already exists.
+ self.assertRaises(ScriptError, self.checkout.apply_patch, self._create_patch(git_binary_addition))
+
+ git_binary_modification = """diff --git a/fizzbuzz7.gif b/fizzbuzz7.gif
+index 64a9532e7794fcd791f6f12157406d9060151690..323fae03f4606ea9991df8befbb2fca7
+GIT binary patch
+literal 7
+OcmYex&reD$;sO8*F9L)B
+
+literal 512
+zcmZ?wbhEHbRAx|MU|?iW{Kxc~?KofD;ckY;H+&5HnHl!!GQMD7h+sU{_)e9f^V3c?
+zhJP##HdZC#4K}7F68@!1jfWQg2daCm-gs#3|JREDT>c+pG4L<_2;w##WMO#ysPPap
+zLqpAf1OE938xAsSp4!5f-o><?VKe(#0jEcwfHGF4%M1^kRs14oVBp2ZEL{E1N<-zJ
+zsfLmOtKta;2_;2c#^S1-8cf<nb!QnGl>c!Xe6RXvrEtAWBvSDTgTO1j3vA31Puw!A
+zs(87q)j_mVDTqBo-P+03-P5mHCEnJ+x}YdCuS7#bCCyePUe(ynK+|4b-3qK)T?Z&)
+zYG+`tl4h?GZv_$t82}X4*DTE|$;{DEiPyF@)U-1+FaX++T9H{&%cag`W1|zVP@`%b
+zqiSkp6{BTpWTkCr!=<C6Q=?#~R8^JfrliAF6Q^gV9Iup8RqCXqqhqC`qsyhk<-nlB
+z00f{QZvfK&|Nm#oZ0TQl`Yr$BIa6A@16O26ud7H<QM=xl`toLKnz-3h@9c9q&wm|X
+z{89I|WPyD!*M?gv?q`;L=2YFeXrJQNti4?}s!zFo=5CzeBxC69xA<zrjP<wUcCRh4
+ptUl-ZG<%a~#LwkIWv&q!KSCH7tQ8cJDiw+|GV?MN)RjY50RTb-xvT&H
+
+"""
+ self.checkout.apply_patch(self._create_patch(git_binary_modification))
+ modified = read_from_path('fizzbuzz7.gif')
+ self.assertEqual('foobar\n', modified)
+ self.assertTrue('fizzbuzz7.gif' in self.scm.changed_files())
+
+ # Applying the same modification should fail.
+ self.assertRaises(ScriptError, self.checkout.apply_patch, self._create_patch(git_binary_modification))
+
+ git_binary_deletion = """diff --git a/fizzbuzz7.gif b/fizzbuzz7.gif
+deleted file mode 100644
+index 323fae0..0000000
+GIT binary patch
+literal 0
+HcmV?d00001
+
+literal 7
+OcmYex&reD$;sO8*F9L)B
+
+"""
+ self.checkout.apply_patch(self._create_patch(git_binary_deletion))
+ self.assertFalse(os.path.exists('fizzbuzz7.gif'))
+ self.assertFalse('fizzbuzz7.gif' in self.scm.changed_files())
+
+ # Cannot delete again.
+ self.assertRaises(ScriptError, self.checkout.apply_patch, self._create_patch(git_binary_deletion))
+
+
+class SVNTest(SCMTest):
+
+ @staticmethod
+ def _set_date_and_reviewer(changelog_entry):
+ # Joe Cool matches the reviewer set in SCMTest._create_patch
+ changelog_entry = changelog_entry.replace('REVIEWER_HERE', 'Joe Cool')
+ # svn-apply will update ChangeLog entries with today's date.
+ return changelog_entry.replace('DATE_HERE', date.today().isoformat())
+
+ def test_svn_apply(self):
+ first_entry = """2009-10-26 Eric Seidel <eric@webkit.org>
+
+ Reviewed by Foo Bar.
+
+ Most awesome change ever.
+
+ * scm_unittest.py:
+"""
+ intermediate_entry = """2009-10-27 Eric Seidel <eric@webkit.org>
+
+ Reviewed by Baz Bar.
+
+ A more awesomer change yet!
+
+ * scm_unittest.py:
+"""
+ one_line_overlap_patch = """Index: ChangeLog
+===================================================================
+--- ChangeLog (revision 5)
++++ ChangeLog (working copy)
+@@ -1,5 +1,13 @@
+ 2009-10-26 Eric Seidel <eric@webkit.org>
+
++ Reviewed by NOBODY (OOPS!).
++
++ Second most awesome change ever.
++
++ * scm_unittest.py:
++
++2009-10-26 Eric Seidel <eric@webkit.org>
++
+ Reviewed by Foo Bar.
+
+ Most awesome change ever.
+"""
+ one_line_overlap_entry = """DATE_HERE Eric Seidel <eric@webkit.org>
+
+ Reviewed by REVIEWER_HERE.
+
+ Second most awesome change ever.
+
+ * scm_unittest.py:
+"""
+ two_line_overlap_patch = """Index: ChangeLog
+===================================================================
+--- ChangeLog (revision 5)
++++ ChangeLog (working copy)
+@@ -2,6 +2,14 @@
+
+ Reviewed by Foo Bar.
+
++ Second most awesome change ever.
++
++ * scm_unittest.py:
++
++2009-10-26 Eric Seidel <eric@webkit.org>
++
++ Reviewed by Foo Bar.
++
+ Most awesome change ever.
+
+ * scm_unittest.py:
+"""
+ two_line_overlap_entry = """DATE_HERE Eric Seidel <eric@webkit.org>
+
+ Reviewed by Foo Bar.
+
+ Second most awesome change ever.
+
+ * scm_unittest.py:
+"""
+ write_into_file_at_path('ChangeLog', first_entry)
+ run_command(['svn', 'add', 'ChangeLog'])
+ run_command(['svn', 'commit', '--quiet', '--message', 'ChangeLog commit'])
+
+ # Patch files were created against just 'first_entry'.
+ # Add a second commit to make svn-apply have to apply the patches with fuzz.
+ changelog_contents = "%s\n%s" % (intermediate_entry, first_entry)
+ write_into_file_at_path('ChangeLog', changelog_contents)
+ run_command(['svn', 'commit', '--quiet', '--message', 'Intermediate commit'])
+
+ self._setup_webkittools_scripts_symlink(self.scm)
+ self.checkout.apply_patch(self._create_patch(one_line_overlap_patch))
+ expected_changelog_contents = "%s\n%s" % (self._set_date_and_reviewer(one_line_overlap_entry), changelog_contents)
+ self.assertEquals(read_from_path('ChangeLog'), expected_changelog_contents)
+
+ self.scm.revert_files(['ChangeLog'])
+ self.checkout.apply_patch(self._create_patch(two_line_overlap_patch))
+ expected_changelog_contents = "%s\n%s" % (self._set_date_and_reviewer(two_line_overlap_entry), changelog_contents)
+ self.assertEquals(read_from_path('ChangeLog'), expected_changelog_contents)
+
+ def setUp(self):
+ SVNTestRepository.setup(self)
+ os.chdir(self.svn_checkout_path)
+ self.scm = detect_scm_system(self.svn_checkout_path)
+ # For historical reasons, we test some checkout code here too.
+ self.checkout = Checkout(self.scm)
+
+ def tearDown(self):
+ SVNTestRepository.tear_down(self)
+
+ def test_create_patch_is_full_patch(self):
+ test_dir_path = os.path.join(self.svn_checkout_path, "test_dir2")
+ os.mkdir(test_dir_path)
+ test_file_path = os.path.join(test_dir_path, 'test_file2')
+ write_into_file_at_path(test_file_path, 'test content')
+ run_command(['svn', 'add', 'test_dir2'])
+
+ # create_patch depends on 'svn-create-patch', so make a dummy version.
+ scripts_path = os.path.join(self.svn_checkout_path, 'WebKitTools', 'Scripts')
+ os.makedirs(scripts_path)
+ create_patch_path = os.path.join(scripts_path, 'svn-create-patch')
+ write_into_file_at_path(create_patch_path, '#!/bin/sh\necho $PWD') # We could pass -n to prevent the \n, but not all echo accept -n.
+ os.chmod(create_patch_path, stat.S_IXUSR | stat.S_IRUSR)
+
+ # Change into our test directory and run the create_patch command.
+ os.chdir(test_dir_path)
+ scm = detect_scm_system(test_dir_path)
+ self.assertEqual(scm.checkout_root, self.svn_checkout_path) # Sanity check that detection worked right.
+ patch_contents = scm.create_patch()
+ # Our fake 'svn-create-patch' returns $PWD instead of a patch, check that it was executed from the root of the repo.
+ self.assertEqual("%s\n" % os.path.realpath(scm.checkout_root), patch_contents) # Add a \n because echo adds a \n.
+
+ def test_detection(self):
+ scm = detect_scm_system(self.svn_checkout_path)
+ self.assertEqual(scm.display_name(), "svn")
+ self.assertEqual(scm.supports_local_commits(), False)
+
+ def test_apply_small_binary_patch(self):
+ patch_contents = """Index: test_file.swf
+===================================================================
+Cannot display: file marked as a binary type.
+svn:mime-type = application/octet-stream
+
+Property changes on: test_file.swf
+___________________________________________________________________
+Name: svn:mime-type
+ + application/octet-stream
+
+
+Q1dTBx0AAAB42itg4GlgYJjGwMDDyODMxMDw34GBgQEAJPQDJA==
+"""
+ expected_contents = base64.b64decode("Q1dTBx0AAAB42itg4GlgYJjGwMDDyODMxMDw34GBgQEAJPQDJA==")
+ self._setup_webkittools_scripts_symlink(self.scm)
+ patch_file = self._create_patch(patch_contents)
+ self.checkout.apply_patch(patch_file)
+ actual_contents = read_from_path("test_file.swf")
+ self.assertEqual(actual_contents, expected_contents)
+
+ def test_apply_svn_patch(self):
+ scm = detect_scm_system(self.svn_checkout_path)
+ patch = self._create_patch(run_command(['svn', 'diff', '-r4:3']))
+ self._setup_webkittools_scripts_symlink(scm)
+ Checkout(scm).apply_patch(patch)
+
+ def test_apply_svn_patch_force(self):
+ scm = detect_scm_system(self.svn_checkout_path)
+ patch = self._create_patch(run_command(['svn', 'diff', '-r2:4']))
+ self._setup_webkittools_scripts_symlink(scm)
+ self.assertRaises(ScriptError, Checkout(scm).apply_patch, patch, force=True)
+
+ def test_commit_logs(self):
+ # Commits have dates and usernames in them, so we can't just direct compare.
+ self.assertTrue(re.search('fourth commit', self.scm.last_svn_commit_log()))
+ self.assertTrue(re.search('second commit', self.scm.svn_commit_log(2)))
+
+ def test_commit_text_parsing(self):
+ self._shared_test_commit_with_message()
+
+ def test_commit_with_username(self):
+ self._shared_test_commit_with_message("dbates@webkit.org")
+
+ def test_has_authorization_for_realm(self):
+ scm = detect_scm_system(self.svn_checkout_path)
+ fake_home_dir = tempfile.mkdtemp(suffix="fake_home_dir")
+ svn_config_dir_path = os.path.join(fake_home_dir, ".subversion")
+ os.mkdir(svn_config_dir_path)
+ fake_webkit_auth_file = os.path.join(svn_config_dir_path, "fake_webkit_auth_file")
+ write_into_file_at_path(fake_webkit_auth_file, SVN.svn_server_realm)
+ self.assertTrue(scm.has_authorization_for_realm(home_directory=fake_home_dir))
+ os.remove(fake_webkit_auth_file)
+ os.rmdir(svn_config_dir_path)
+ os.rmdir(fake_home_dir)
+
+ def test_not_have_authorization_for_realm(self):
+ scm = detect_scm_system(self.svn_checkout_path)
+ fake_home_dir = tempfile.mkdtemp(suffix="fake_home_dir")
+ svn_config_dir_path = os.path.join(fake_home_dir, ".subversion")
+ os.mkdir(svn_config_dir_path)
+ self.assertFalse(scm.has_authorization_for_realm(home_directory=fake_home_dir))
+ os.rmdir(svn_config_dir_path)
+ os.rmdir(fake_home_dir)
+
+ def test_reverse_diff(self):
+ self._shared_test_reverse_diff()
+
+ def test_diff_for_revision(self):
+ self._shared_test_diff_for_revision()
+
+ def test_svn_apply_git_patch(self):
+ self._shared_test_svn_apply_git_patch()
+
+ def test_changed_files(self):
+ self._shared_test_changed_files()
+
+ def test_changed_files_for_revision(self):
+ self._shared_test_changed_files_for_revision()
+
+ def test_added_files(self):
+ self._shared_test_added_files()
+
+ def test_contents_at_revision(self):
+ self._shared_test_contents_at_revision()
+
+ def test_committer_email_for_revision(self):
+ self._shared_test_committer_email_for_revision()
+
+
+class GitTest(SCMTest):
+
+ def _setup_git_clone_of_svn_repository(self):
+ self.git_checkout_path = tempfile.mkdtemp(suffix="git_test_checkout")
+ # --quiet doesn't make git svn silent, so we use run_silent to redirect output
+ run_silent(['git', 'svn', '--quiet', 'clone', self.svn_repo_url, self.git_checkout_path])
+
+ def _tear_down_git_clone_of_svn_repository(self):
+ run_command(['rm', '-rf', self.git_checkout_path])
+
+ def setUp(self):
+ SVNTestRepository.setup(self)
+ self._setup_git_clone_of_svn_repository()
+ os.chdir(self.git_checkout_path)
+ self.scm = detect_scm_system(self.git_checkout_path)
+ # For historical reasons, we test some checkout code here too.
+ self.checkout = Checkout(self.scm)
+
+ def tearDown(self):
+ SVNTestRepository.tear_down(self)
+ self._tear_down_git_clone_of_svn_repository()
+
+ def test_detection(self):
+ scm = detect_scm_system(self.git_checkout_path)
+ self.assertEqual(scm.display_name(), "git")
+ self.assertEqual(scm.supports_local_commits(), True)
+
+ def test_read_git_config(self):
+ key = 'test.git-config'
+ value = 'git-config value'
+ run_command(['git', 'config', key, value])
+ self.assertEqual(self.scm.read_git_config(key), value)
+
+ def test_local_commits(self):
+ test_file = os.path.join(self.git_checkout_path, 'test_file')
+ write_into_file_at_path(test_file, 'foo')
+ run_command(['git', 'commit', '-a', '-m', 'local commit'])
+
+ self.assertEqual(len(self.scm.local_commits()), 1)
+
+ def test_discard_local_commits(self):
+ test_file = os.path.join(self.git_checkout_path, 'test_file')
+ write_into_file_at_path(test_file, 'foo')
+ run_command(['git', 'commit', '-a', '-m', 'local commit'])
+
+ self.assertEqual(len(self.scm.local_commits()), 1)
+ self.scm.discard_local_commits()
+ self.assertEqual(len(self.scm.local_commits()), 0)
+
+ def test_delete_branch(self):
+ old_branch = run_command(['git', 'symbolic-ref', 'HEAD']).strip()
+ new_branch = 'foo'
+
+ run_command(['git', 'checkout', '-b', new_branch])
+ self.assertEqual(run_command(['git', 'symbolic-ref', 'HEAD']).strip(), 'refs/heads/' + new_branch)
+
+ run_command(['git', 'checkout', old_branch])
+ self.scm.delete_branch(new_branch)
+
+ self.assertFalse(re.search(r'foo', run_command(['git', 'branch'])))
+
+ def test_svn_merge_base(self):
+ # Diff to merge-base should include working-copy changes,
+ # which the diff to svn_branch.. doesn't.
+ test_file = os.path.join(self.git_checkout_path, 'test_file')
+ write_into_file_at_path(test_file, 'foo')
+
+ diff_to_common_base = run_command(['git', 'diff', self.scm.svn_branch_name() + '..'])
+ diff_to_merge_base = run_command(['git', 'diff', self.scm.svn_merge_base()])
+
+ self.assertFalse(re.search(r'foo', diff_to_common_base))
+ self.assertTrue(re.search(r'foo', diff_to_merge_base))
+
+ def test_rebase_in_progress(self):
+ svn_test_file = os.path.join(self.svn_checkout_path, 'test_file')
+ write_into_file_at_path(svn_test_file, "svn_checkout")
+ run_command(['svn', 'commit', '--message', 'commit to conflict with git commit'], cwd=self.svn_checkout_path)
+
+ git_test_file = os.path.join(self.git_checkout_path, 'test_file')
+ write_into_file_at_path(git_test_file, "git_checkout")
+ run_command(['git', 'commit', '-a', '-m', 'commit to be thrown away by rebase abort'])
+
+ # --quiet doesn't make git svn silent, so use run_silent to redirect output
+ self.assertRaises(ScriptError, run_silent, ['git', 'svn', '--quiet', 'rebase']) # Will fail due to a conflict leaving us mid-rebase.
+
+ scm = detect_scm_system(self.git_checkout_path)
+ self.assertTrue(scm.rebase_in_progress())
+
+ # Make sure our cleanup works.
+ scm.clean_working_directory()
+ self.assertFalse(scm.rebase_in_progress())
+
+ # Make sure cleanup doesn't throw when no rebase is in progress.
+ scm.clean_working_directory()
+
+ def test_commitish_parsing(self):
+ scm = detect_scm_system(self.git_checkout_path)
+
+ # Multiple revisions are cherry-picked.
+ self.assertEqual(len(scm.commit_ids_from_commitish_arguments(['HEAD~2'])), 1)
+ self.assertEqual(len(scm.commit_ids_from_commitish_arguments(['HEAD', 'HEAD~2'])), 2)
+
+ # ... is an invalid range specifier
+ self.assertRaises(ScriptError, scm.commit_ids_from_commitish_arguments, ['trunk...HEAD'])
+
+ def test_commitish_order(self):
+ scm = detect_scm_system(self.git_checkout_path)
+
+ commit_range = 'HEAD~3..HEAD'
+
+ actual_commits = scm.commit_ids_from_commitish_arguments([commit_range])
+ expected_commits = []
+ expected_commits += reversed(run_command(['git', 'rev-list', commit_range]).splitlines())
+
+ self.assertEqual(actual_commits, expected_commits)
+
+ def test_apply_git_patch(self):
+ scm = detect_scm_system(self.git_checkout_path)
+ # We carefullly pick a diff which does not have a directory addition
+ # as currently svn-apply will error out when trying to remove directories
+ # in Git: https://bugs.webkit.org/show_bug.cgi?id=34871
+ patch = self._create_patch(run_command(['git', 'diff', 'HEAD..HEAD^']))
+ self._setup_webkittools_scripts_symlink(scm)
+ Checkout(scm).apply_patch(patch)
+
+ def test_apply_git_patch_force(self):
+ scm = detect_scm_system(self.git_checkout_path)
+ patch = self._create_patch(run_command(['git', 'diff', 'HEAD~2..HEAD']))
+ self._setup_webkittools_scripts_symlink(scm)
+ self.assertRaises(ScriptError, Checkout(scm).apply_patch, patch, force=True)
+
+ def test_commit_text_parsing(self):
+ self._shared_test_commit_with_message()
+
+ def test_reverse_diff(self):
+ self._shared_test_reverse_diff()
+
+ def test_diff_for_revision(self):
+ self._shared_test_diff_for_revision()
+
+ def test_svn_apply_git_patch(self):
+ self._shared_test_svn_apply_git_patch()
+
+ def test_create_binary_patch(self):
+ # Create a git binary patch and check the contents.
+ scm = detect_scm_system(self.git_checkout_path)
+ test_file_name = 'binary_file'
+ test_file_path = os.path.join(self.git_checkout_path, test_file_name)
+ file_contents = ''.join(map(chr, range(256)))
+ write_into_file_at_path(test_file_path, file_contents)
+ run_command(['git', 'add', test_file_name])
+ patch = scm.create_patch()
+ self.assertTrue(re.search(r'\nliteral 0\n', patch))
+ self.assertTrue(re.search(r'\nliteral 256\n', patch))
+
+ # Check if we can apply the created patch.
+ run_command(['git', 'rm', '-f', test_file_name])
+ self._setup_webkittools_scripts_symlink(scm)
+ self.checkout.apply_patch(self._create_patch(patch))
+ self.assertEqual(file_contents, read_from_path(test_file_path))
+
+ # Check if we can create a patch from a local commit.
+ write_into_file_at_path(test_file_path, file_contents)
+ run_command(['git', 'add', test_file_name])
+ run_command(['git', 'commit', '-m', 'binary diff'])
+ patch_from_local_commit = scm.create_patch_from_local_commit('HEAD')
+ self.assertTrue(re.search(r'\nliteral 0\n', patch_from_local_commit))
+ self.assertTrue(re.search(r'\nliteral 256\n', patch_from_local_commit))
+ patch_since_local_commit = scm.create_patch_since_local_commit('HEAD^1')
+ self.assertTrue(re.search(r'\nliteral 0\n', patch_since_local_commit))
+ self.assertTrue(re.search(r'\nliteral 256\n', patch_since_local_commit))
+ self.assertEqual(patch_from_local_commit, patch_since_local_commit)
+
+ def test_changed_files(self):
+ self._shared_test_changed_files()
+
+ def test_changed_files_for_revision(self):
+ self._shared_test_changed_files_for_revision()
+
+ def test_contents_at_revision(self):
+ self._shared_test_contents_at_revision()
+
+ def test_added_files(self):
+ self._shared_test_added_files()
+
+ def test_committer_email_for_revision(self):
+ self._shared_test_committer_email_for_revision()
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/WebKitTools/Scripts/webkitpy/common/config/__init__.py b/WebKitTools/Scripts/webkitpy/common/config/__init__.py
new file mode 100644
index 0000000..03f1bc7
--- /dev/null
+++ b/WebKitTools/Scripts/webkitpy/common/config/__init__.py
@@ -0,0 +1,7 @@
+# Required for Python to search this directory for module files
+
+import re
+
+codereview_server_host = "wkrietveld.appspot.com"
+codereview_server_regex = "https?://%s/" % re.sub('\.', '\\.', codereview_server_host)
+codereview_server_url = "https://%s/" % codereview_server_host
diff --git a/WebKitTools/Scripts/webkitpy/common/config/committers.py b/WebKitTools/Scripts/webkitpy/common/config/committers.py
new file mode 100644
index 0000000..a92dbd3
--- /dev/null
+++ b/WebKitTools/Scripts/webkitpy/common/config/committers.py
@@ -0,0 +1,288 @@
+# Copyright (c) 2009, Google Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# * Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following disclaimer
+# in the documentation and/or other materials provided with the
+# distribution.
+# * Neither the name of Google Inc. nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+#
+# WebKit's Python module for committer and reviewer validation
+
+
+class Committer:
+
+ def __init__(self, name, email_or_emails, irc_nickname=None):
+ self.full_name = name
+ if isinstance(email_or_emails, str):
+ self.emails = [email_or_emails]
+ else:
+ self.emails = email_or_emails
+ self.irc_nickname = irc_nickname
+ self.can_review = False
+
+ def bugzilla_email(self):
+ # FIXME: We're assuming the first email is a valid bugzilla email,
+ # which might not be right.
+ return self.emails[0]
+
+ def __str__(self):
+ return '"%s" <%s>' % (self.full_name, self.emails[0])
+
+
+class Reviewer(Committer):
+
+ def __init__(self, name, email_or_emails, irc_nickname=None):
+ Committer.__init__(self, name, email_or_emails, irc_nickname)
+ self.can_review = True
+
+
+# This is intended as a canonical, machine-readable list of all non-reviewer
+# committers for WebKit. If your name is missing here and you are a committer,
+# please add it. No review needed. All reviewers are committers, so this list
+# is only of committers who are not reviewers.
+
+
+committers_unable_to_review = [
+ Committer("Aaron Boodman", "aa@chromium.org", "aboodman"),
+ Committer("Adam Langley", "agl@chromium.org", "agl"),
+ Committer("Albert J. Wong", "ajwong@chromium.org"),
+ Committer("Alejandro G. Castro", ["alex@igalia.com", "alex@webkit.org"]),
+ Committer("Alexander Kellett", ["lypanov@mac.com", "a-lists001@lypanov.net", "lypanov@kde.org"], "lypanov"),
+ Committer("Alexander Pavlov", "apavlov@chromium.org"),
+ Committer("Andre Boule", "aboule@apple.com"),
+ Committer("Andrew Wellington", ["andrew@webkit.org", "proton@wiretapped.net"], "proton"),
+ Committer("Andras Becsi", "abecsi@webkit.org", "bbandix"),
+ Committer("Andy Estes", "aestes@apple.com", "estes"),
+ Committer("Anthony Ricaud", "rik@webkit.org", "rik"),
+ Committer("Anton Muhin", "antonm@chromium.org", "antonm"),
+ Committer("Antonio Gomes", "tonikitoo@webkit.org", "tonikitoo"),
+ Committer("Ben Murdoch", "benm@google.com", "benm"),
+ Committer("Benjamin C Meyer", ["ben@meyerhome.net", "ben@webkit.org"], "icefox"),
+ Committer("Benjamin Otte", ["otte@gnome.org", "otte@webkit.org"], "otte"),
+ Committer("Brent Fulgham", "bfulgham@webkit.org", "bfulgham"),
+ Committer("Brett Wilson", "brettw@chromium.org", "brettx"),
+ Committer("Brian Weinstein", "bweinstein@apple.com", "bweinstein"),
+ Committer("Cameron McCormack", "cam@webkit.org", "heycam"),
+ Committer("Carol Szabo", "carol.szabo@nokia.com"),
+ Committer("Chang Shu", "Chang.Shu@nokia.com"),
+ Committer("Chris Fleizach", "cfleizach@apple.com"),
+ Committer("Chris Jerdonek", "cjerdonek@webkit.org", "cjerdonek"),
+ Committer("Chris Marrin", "cmarrin@apple.com", "cmarrin"),
+ Committer("Chris Petersen", "cpetersen@apple.com", "cpetersen"),
+ Committer("Christian Dywan", ["christian@twotoasts.de", "christian@webkit.org"]),
+ Committer("Collin Jackson", "collinj@webkit.org"),
+ Committer("Csaba Osztrogonac", "ossy@webkit.org", "ossy"),
+ Committer("David Smith", ["catfish.man@gmail.com", "dsmith@webkit.org"], "catfishman"),
+ Committer("Dean Jackson", "dino@apple.com", "dino"),
+ Committer("Dirk Pranke", "dpranke@chromium.org"),
+ Committer("Drew Wilson", "atwilson@chromium.org", "atwilson"),
+ Committer("Dumitru Daniliuc", "dumi@chromium.org", "dumi"),
+ Committer("Eli Fidler", "eli@staikos.net", "QBin"),
+ Committer("Enrica Casucci", "enrica@apple.com"),
+ Committer("Erik Arvidsson", "arv@chromium.org", "arv"),
+ Committer("Eric Roman", "eroman@chromium.org", "eroman"),
+ Committer("Feng Qian", "feng@chromium.org"),
+ Committer("Fumitoshi Ukai", "ukai@chromium.org", "ukai"),
+ Committer("Gabor Loki", "loki@webkit.org", "loki04"),
+ Committer("Girish Ramakrishnan", ["girish@forwardbias.in", "ramakrishnan.girish@gmail.com"]),
+ Committer("Graham Dennis", ["Graham.Dennis@gmail.com", "gdennis@webkit.org"]),
+ Committer("Greg Bolsinga", "bolsinga@apple.com"),
+ Committer("Hin-Chung Lam", ["hclam@google.com", "hclam@chromium.org"]),
+ Committer("Ilya Tikhonovsky", "loislo@chromium.org", "loislo"),
+ Committer("Jakob Petsovits", ["jpetsovits@rim.com", "jpetso@gmx.at"], "jpetso"),
+ Committer("Jakub Wieczorek", "jwieczorek@webkit.org", "fawek"),
+ Committer("James Hawkins", ["jhawkins@chromium.org", "jhawkins@google.com"], "jhawkins"),
+ Committer("James Robinson", ["jamesr@chromium.org", "jamesr@google.com"]),
+ Committer("Jens Alfke", ["snej@chromium.org", "jens@apple.com"]),
+ Committer("Jeremy Moskovich", ["playmobil@google.com", "jeremy@chromium.org"], "jeremymos"),
+ Committer("Jessie Berlin", ["jberlin@webkit.org", "jberlin@apple.com"]),
+ Committer("Jesus Sanchez-Palencia", ["jesus@webkit.org", "jesus.palencia@openbossa.org"], "jeez_"),
+ Committer("John Abd-El-Malek", "jam@chromium.org", "jam"),
+ Committer("John Gregg", ["johnnyg@google.com", "johnnyg@chromium.org"], "johnnyg"),
+ Committer("Joost de Valk", ["joost@webkit.org", "webkit-dev@joostdevalk.nl"], "Altha"),
+ Committer("Julie Parent", ["jparent@google.com", "jparent@chromium.org"], "jparent"),
+ Committer("Julien Chaffraix", ["jchaffraix@webkit.org", "julien.chaffraix@gmail.com"]),
+ Committer("Jungshik Shin", "jshin@chromium.org"),
+ Committer("Keishi Hattori", "keishi@webkit.org", "keishi"),
+ Committer("Kelly Norton", "knorton@google.com"),
+ Committer("Kenneth Russell", "kbr@google.com"),
+ Committer("Kent Tamura", "tkent@chromium.org", "tkent"),
+ Committer("Kinuko Yasuda", "kinuko@chromium.org", "kinuko"),
+ Committer("Krzysztof Kowalczyk", "kkowalczyk@gmail.com"),
+ Committer("Levi Weintraub", "lweintraub@apple.com"),
+ Committer("Mads Ager", "ager@chromium.org"),
+ Committer("Matt Lilek", ["webkit@mattlilek.com", "pewtermoose@webkit.org"]),
+ Committer("Matt Perry", "mpcomplete@chromium.org"),
+ Committer("Maxime Britto", ["maxime.britto@gmail.com", "britto@apple.com"]),
+ Committer("Maxime Simon", ["simon.maxime@gmail.com", "maxime.simon@webkit.org"], "maxime.simon"),
+ Committer("Martin Robinson", ["mrobinson@webkit.org", "martin.james.robinson@gmail.com"]),
+ Committer("Michelangelo De Simone", "michelangelo@webkit.org", "michelangelo"),
+ Committer("Mike Belshe", ["mbelshe@chromium.org", "mike@belshe.com"]),
+ Committer("Mike Fenton", ["mike.fenton@torchmobile.com", "mifenton@rim.com"], "mfenton"),
+ Committer("Mike Thole", ["mthole@mikethole.com", "mthole@apple.com"]),
+ Committer("Mikhail Naganov", "mnaganov@chromium.org"),
+ Committer("MORITA Hajime", "morrita@google.com", "morrita"),
+ Committer("Ojan Vafai", "ojan@chromium.org", "ojan"),
+ Committer("Pam Greene", "pam@chromium.org", "pamg"),
+ Committer("Peter Kasting", ["pkasting@google.com", "pkasting@chromium.org"], "pkasting"),
+ Committer("Philippe Normand", ["pnormand@igalia.com", "philn@webkit.org"], "pnormand"),
+ Committer("Pierre d'Herbemont", ["pdherbemont@free.fr", "pdherbemont@apple.com"], "pdherbemont"),
+ Committer("Pierre-Olivier Latour", "pol@apple.com", "pol"),
+ Committer("Robert Hogan", ["robert@webkit.org", "robert@roberthogan.net"], "mwenge"),
+ Committer("Roland Steiner", "rolandsteiner@chromium.org"),
+ Committer("Ryosuke Niwa", "rniwa@webkit.org", "rniwa"),
+ Committer("Scott Violet", "sky@chromium.org", "sky"),
+ Committer("Stephen White", "senorblanco@chromium.org", "senorblanco"),
+ Committer("Steve Block", "steveblock@google.com"),
+ Committer("Tony Chang", "tony@chromium.org", "tony^work"),
+ Committer("Trey Matteson", "trey@usa.net", "trey"),
+ Committer("Tristan O'Tierney", ["tristan@otierney.net", "tristan@apple.com"]),
+ Committer("Victor Wang", "victorw@chromium.org"),
+ Committer("Vitaly Repeshko", "vitalyr@chromium.org"),
+ Committer("William Siegrist", "wsiegrist@apple.com", "wms"),
+ Committer("Yael Aharon", "yael.aharon@nokia.com"),
+ Committer("Yaar Schnitman", ["yaar@chromium.org", "yaar@google.com"]),
+ Committer("Yong Li", ["yong.li@torchmobile.com", "yong.li.webkit@gmail.com"], "yong"),
+ Committer("Yongjun Zhang", "yongjun.zhang@nokia.com"),
+ Committer("Yuzo Fujishima", "yuzo@google.com", "yuzo"),
+ Committer("Zoltan Herczeg", "zherczeg@webkit.org", "zherczeg"),
+ Committer("Zoltan Horvath", "zoltan@webkit.org", "zoltan"),
+]
+
+
+# This is intended as a canonical, machine-readable list of all reviewers for
+# WebKit. If your name is missing here and you are a reviewer, please add it.
+# No review needed.
+
+
+reviewers_list = [
+ Reviewer("Ada Chan", "adachan@apple.com", "chanada"),
+ Reviewer("Adam Barth", "abarth@webkit.org", "abarth"),
+ Reviewer("Adam Roben", "aroben@apple.com", "aroben"),
+ Reviewer("Adam Treat", ["treat@kde.org", "treat@webkit.org"], "manyoso"),
+ Reviewer("Adele Peterson", "adele@apple.com", "adele"),
+ Reviewer("Alexey Proskuryakov", ["ap@webkit.org", "ap@apple.com"], "ap"),
+ Reviewer("Alice Liu", "alice.liu@apple.com", "aliu"),
+ Reviewer("Alp Toker", ["alp@nuanti.com", "alp@atoker.com", "alp@webkit.org"], "alp"),
+ Reviewer("Anders Carlsson", ["andersca@apple.com", "acarlsson@apple.com"], "andersca"),
+ Reviewer("Antti Koivisto", ["koivisto@iki.fi", "antti@apple.com"], "anttik"),
+ Reviewer("Ariya Hidayat", ["ariya.hidayat@gmail.com", "ariya@webkit.org"], "ariya"),
+ Reviewer("Beth Dakin", "bdakin@apple.com", "dethbakin"),
+ Reviewer("Brady Eidson", "beidson@apple.com", "bradee-oh"),
+ Reviewer("Cameron Zwarich", ["zwarich@apple.com", "cwzwarich@apple.com", "cwzwarich@webkit.org"]),
+ Reviewer("Chris Blumenberg", "cblu@apple.com", "cblu"),
+ Reviewer("Dan Bernstein", ["mitz@webkit.org", "mitz@apple.com"], "mitzpettel"),
+ Reviewer("Daniel Bates", "dbates@webkit.org", "dydz"),
+ Reviewer("Darin Adler", "darin@apple.com", "darin"),
+ Reviewer("Darin Fisher", ["fishd@chromium.org", "darin@chromium.org"], "fishd"),
+ Reviewer("David Harrison", "harrison@apple.com", "harrison"),
+ Reviewer("David Hyatt", "hyatt@apple.com", "hyatt"),
+ Reviewer("David Kilzer", ["ddkilzer@webkit.org", "ddkilzer@apple.com"], "ddkilzer"),
+ Reviewer("David Levin", "levin@chromium.org", "dave_levin"),
+ Reviewer("Dimitri Glazkov", "dglazkov@chromium.org", "dglazkov"),
+ Reviewer("Dirk Schulze", "krit@webkit.org", "krit"),
+ Reviewer("Dmitry Titov", "dimich@chromium.org", "dimich"),
+ Reviewer("Don Melton", "gramps@apple.com", "gramps"),
+ Reviewer("Eric Carlson", "eric.carlson@apple.com"),
+ Reviewer("Eric Seidel", "eric@webkit.org", "eseidel"),
+ Reviewer("Gavin Barraclough", "barraclough@apple.com", "gbarra"),
+ Reviewer("Geoffrey Garen", "ggaren@apple.com", "ggaren"),
+ Reviewer("George Staikos", ["staikos@kde.org", "staikos@webkit.org"]),
+ Reviewer("Gustavo Noronha Silva", ["gns@gnome.org", "kov@webkit.org"], "kov"),
+ Reviewer("Holger Freyther", ["zecke@selfish.org", "zecke@webkit.org"], "zecke"),
+ Reviewer("Jan Alonzo", ["jmalonzo@gmail.com", "jmalonzo@webkit.org"], "janm"),
+ Reviewer("Jeremy Orlow", "jorlow@chromium.org", "jorlow"),
+ Reviewer("Jian Li", "jianli@chromium.org", "jianli"),
+ Reviewer("John Sullivan", "sullivan@apple.com", "sullivan"),
+ Reviewer("Jon Honeycutt", "jhoneycutt@apple.com", "jhoneycutt"),
+ Reviewer("Joseph Pecoraro", "joepeck@webkit.org", "JoePeck"),
+ Reviewer("Justin Garcia", "justin.garcia@apple.com", "justing"),
+ Reviewer("Ken Kocienda", "kocienda@apple.com"),
+ Reviewer("Kenneth Rohde Christiansen", ["kenneth@webkit.org", "kenneth.christiansen@openbossa.org"], "kenne"),
+ Reviewer("Kevin Decker", "kdecker@apple.com", "superkevin"),
+ Reviewer("Kevin McCullough", "kmccullough@apple.com", "maculloch"),
+ Reviewer("Kevin Ollivier", ["kevino@theolliviers.com", "kevino@webkit.org"], "kollivier"),
+ Reviewer("Lars Knoll", ["lars@trolltech.com", "lars@kde.org"], "lars"),
+ Reviewer("Laszlo Gombos", "laszlo.1.gombos@nokia.com", "lgombos"),
+ Reviewer("Maciej Stachowiak", "mjs@apple.com", "othermaciej"),
+ Reviewer("Mark Rowe", "mrowe@apple.com", "bdash"),
+ Reviewer("Nate Chapin", "japhet@chromium.org", "japhet"),
+ Reviewer("Nikolas Zimmermann", ["zimmermann@kde.org", "zimmermann@physik.rwth-aachen.de", "zimmermann@webkit.org"], "wildfox"),
+ Reviewer("Oliver Hunt", "oliver@apple.com", "olliej"),
+ Reviewer("Pavel Feldman", "pfeldman@chromium.org", "pfeldman"),
+ Reviewer("Richard Williamson", "rjw@apple.com", "rjw"),
+ Reviewer("Rob Buis", ["rwlbuis@gmail.com", "rwlbuis@webkit.org"], "rwlbuis"),
+ Reviewer("Sam Weinig", ["sam@webkit.org", "weinig@apple.com"], "weinig"),
+ Reviewer("Shinichiro Hamaji", "hamaji@chromium.org", "hamaji"),
+ Reviewer("Simon Fraser", "simon.fraser@apple.com", "smfr"),
+ Reviewer("Simon Hausmann", ["hausmann@webkit.org", "hausmann@kde.org", "simon.hausmann@nokia.com"], "tronical"),
+ Reviewer("Stephanie Lewis", "slewis@apple.com", "sundiamonde"),
+ Reviewer("Steve Falkenburg", "sfalken@apple.com", "sfalken"),
+ Reviewer("Tim Omernick", "timo@apple.com"),
+ Reviewer("Timothy Hatcher", ["timothy@hatcher.name", "timothy@apple.com"], "xenon"),
+ Reviewer(u'Tor Arne Vestb\xf8', "vestbo@webkit.org", "torarne"),
+ Reviewer("Vicki Murley", "vicki@apple.com"),
+ Reviewer("Xan Lopez", ["xan.lopez@gmail.com", "xan@gnome.org", "xan@webkit.org"], "xan"),
+ Reviewer("Yury Semikhatsky", "yurys@chromium.org", "yurys"),
+ Reviewer("Zack Rusin", "zack@kde.org", "zackr"),
+]
+
+
+class CommitterList:
+
+ # Committers and reviewers are passed in to allow easy testing
+
+ def __init__(self,
+ committers=committers_unable_to_review,
+ reviewers=reviewers_list):
+ self._committers = committers + reviewers
+ self._reviewers = reviewers
+ self._committers_by_email = {}
+
+ def committers(self):
+ return self._committers
+
+ def reviewers(self):
+ return self._reviewers
+
+ def _email_to_committer_map(self):
+ if not len(self._committers_by_email):
+ for committer in self._committers:
+ for email in committer.emails:
+ self._committers_by_email[email] = committer
+ return self._committers_by_email
+
+ def committer_by_name(self, name):
+ # This could be made into a hash lookup if callers need it to be fast.
+ for committer in self.committers():
+ if committer.full_name == name:
+ return committer
+
+ def committer_by_email(self, email):
+ return self._email_to_committer_map().get(email)
+
+ def reviewer_by_email(self, email):
+ committer = self.committer_by_email(email)
+ if committer and not committer.can_review:
+ return None
+ return committer
diff --git a/WebKitTools/Scripts/webkitpy/common/config/committers_unittest.py b/WebKitTools/Scripts/webkitpy/common/config/committers_unittest.py
new file mode 100644
index 0000000..068c0ee
--- /dev/null
+++ b/WebKitTools/Scripts/webkitpy/common/config/committers_unittest.py
@@ -0,0 +1,72 @@
+# Copyright (C) 2009 Google Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# * Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following disclaimer
+# in the documentation and/or other materials provided with the
+# distribution.
+# * Neither the name of Google Inc. nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import unittest
+from webkitpy.common.config.committers import CommitterList, Committer, Reviewer
+
+class CommittersTest(unittest.TestCase):
+
+ def test_committer_lookup(self):
+ committer = Committer('Test One', 'one@test.com', 'one')
+ reviewer = Reviewer('Test Two', ['two@test.com', 'two@rad.com', 'so_two@gmail.com'])
+ committer_list = CommitterList(committers=[committer], reviewers=[reviewer])
+
+ # Test valid committer and reviewer lookup
+ self.assertEqual(committer_list.committer_by_email('one@test.com'), committer)
+ self.assertEqual(committer_list.reviewer_by_email('two@test.com'), reviewer)
+ self.assertEqual(committer_list.committer_by_email('two@test.com'), reviewer)
+ self.assertEqual(committer_list.committer_by_email('two@rad.com'), reviewer)
+ self.assertEqual(committer_list.reviewer_by_email('so_two@gmail.com'), reviewer)
+
+ # Test valid committer and reviewer lookup
+ self.assertEqual(committer_list.committer_by_name("Test One"), committer)
+ self.assertEqual(committer_list.committer_by_name("Test Two"), reviewer)
+ self.assertEqual(committer_list.committer_by_name("Test Three"), None)
+
+ # Test that the first email is assumed to be the Bugzilla email address (for now)
+ self.assertEqual(committer_list.committer_by_email('two@rad.com').bugzilla_email(), 'two@test.com')
+
+ # Test that a known committer is not returned during reviewer lookup
+ self.assertEqual(committer_list.reviewer_by_email('one@test.com'), None)
+
+ # Test that unknown email address fail both committer and reviewer lookup
+ self.assertEqual(committer_list.committer_by_email('bar@bar.com'), None)
+ self.assertEqual(committer_list.reviewer_by_email('bar@bar.com'), None)
+
+ # Test that emails returns a list.
+ self.assertEqual(committer.emails, ['one@test.com'])
+
+ self.assertEqual(committer.irc_nickname, 'one')
+
+ # Test that committers returns committers and reviewers and reviewers() just reviewers.
+ self.assertEqual(committer_list.committers(), [committer, reviewer])
+ self.assertEqual(committer_list.reviewers(), [reviewer])
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/WebKitTools/Scripts/webkitpy/common/config/irc.py b/WebKitTools/Scripts/webkitpy/common/config/irc.py
new file mode 100644
index 0000000..950c573
--- /dev/null
+++ b/WebKitTools/Scripts/webkitpy/common/config/irc.py
@@ -0,0 +1,31 @@
+# Copyright (c) 2009 Google Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# * Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following disclaimer
+# in the documentation and/or other materials provided with the
+# distribution.
+# * Neither the name of Google Inc. nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+server="irc.freenode.net"
+port=6667
+channel="#webkit"
diff --git a/WebKitTools/Scripts/webkitpy/common/config/ports.py b/WebKitTools/Scripts/webkitpy/common/config/ports.py
new file mode 100644
index 0000000..a881a67
--- /dev/null
+++ b/WebKitTools/Scripts/webkitpy/common/config/ports.py
@@ -0,0 +1,190 @@
+# Copyright (C) 2009, Google Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# * Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following disclaimer
+# in the documentation and/or other materials provided with the
+# distribution.
+# * Neither the name of Google Inc. nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+#
+# WebKit's Python module for understanding the various ports
+
+import os
+import platform
+
+from webkitpy.common.system.executive import Executive
+
+
+class WebKitPort(object):
+
+ # We might need to pass scm into this function for scm.checkout_root
+ @classmethod
+ def script_path(cls, script_name):
+ return os.path.join("WebKitTools", "Scripts", script_name)
+
+ @staticmethod
+ def port(port_name):
+ ports = {
+ "chromium": ChromiumPort,
+ "gtk": GtkPort,
+ "mac": MacPort,
+ "win": WinPort,
+ "qt": QtPort,
+ }
+ default_port = {
+ "Windows": WinPort,
+ "Darwin": MacPort,
+ }
+ # Do we really need MacPort as the ultimate default?
+ return ports.get(port_name, default_port.get(platform.system(), MacPort))
+
+ @staticmethod
+ def makeArgs():
+ args = '--makeargs="-j%s"' % Executive().cpu_count()
+ if os.environ.has_key('MAKEFLAGS'):
+ args = '--makeargs="%s"' % os.environ['MAKEFLAGS']
+ return args
+
+ @classmethod
+ def name(cls):
+ raise NotImplementedError("subclasses must implement")
+
+ @classmethod
+ def flag(cls):
+ raise NotImplementedError("subclasses must implement")
+
+ @classmethod
+ def update_webkit_command(cls):
+ return [cls.script_path("update-webkit")]
+
+ @classmethod
+ def build_webkit_command(cls, build_style=None):
+ command = [cls.script_path("build-webkit")]
+ if build_style == "debug":
+ command.append("--debug")
+ if build_style == "release":
+ command.append("--release")
+ return command
+
+ @classmethod
+ def run_javascriptcore_tests_command(cls):
+ return [cls.script_path("run-javascriptcore-tests")]
+
+ @classmethod
+ def run_webkit_tests_command(cls):
+ return [cls.script_path("run-webkit-tests")]
+
+ @classmethod
+ def run_python_unittests_command(cls):
+ return [cls.script_path("test-webkitpy")]
+
+ @classmethod
+ def run_perl_unittests_command(cls):
+ return [cls.script_path("test-webkitperl")]
+
+
+class MacPort(WebKitPort):
+
+ @classmethod
+ def name(cls):
+ return "Mac"
+
+ @classmethod
+ def flag(cls):
+ return "--port=mac"
+
+
+class WinPort(WebKitPort):
+
+ @classmethod
+ def name(cls):
+ return "Win"
+
+ @classmethod
+ def flag(cls):
+ # FIXME: This is lame. We should autogenerate this from a codename or something.
+ return "--port=win"
+
+
+class GtkPort(WebKitPort):
+
+ @classmethod
+ def name(cls):
+ return "Gtk"
+
+ @classmethod
+ def flag(cls):
+ return "--port=gtk"
+
+ @classmethod
+ def build_webkit_command(cls, build_style=None):
+ command = WebKitPort.build_webkit_command(build_style=build_style)
+ command.append("--gtk")
+ command.append(WebKitPort.makeArgs())
+ return command
+
+ @classmethod
+ def run_webkit_tests_command(cls):
+ command = WebKitPort.run_webkit_tests_command()
+ command.append("--gtk")
+ return command
+
+
+class QtPort(WebKitPort):
+
+ @classmethod
+ def name(cls):
+ return "Qt"
+
+ @classmethod
+ def flag(cls):
+ return "--port=qt"
+
+ @classmethod
+ def build_webkit_command(cls, build_style=None):
+ command = WebKitPort.build_webkit_command(build_style=build_style)
+ command.append("--qt")
+ command.append(WebKitPort.makeArgs())
+ return command
+
+
+class ChromiumPort(WebKitPort):
+
+ @classmethod
+ def name(cls):
+ return "Chromium"
+
+ @classmethod
+ def flag(cls):
+ return "--port=chromium"
+
+ @classmethod
+ def update_webkit_command(cls):
+ command = WebKitPort.update_webkit_command()
+ command.append("--chromium")
+ return command
+
+ @classmethod
+ def build_webkit_command(cls, build_style=None):
+ command = WebKitPort.build_webkit_command(build_style=build_style)
+ command.append("--chromium")
+ return command
diff --git a/WebKitTools/Scripts/webkitpy/common/config/ports_unittest.py b/WebKitTools/Scripts/webkitpy/common/config/ports_unittest.py
new file mode 100644
index 0000000..c42d2d0
--- /dev/null
+++ b/WebKitTools/Scripts/webkitpy/common/config/ports_unittest.py
@@ -0,0 +1,68 @@
+#!/usr/bin/env python
+# Copyright (c) 2009, Google Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# * Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following disclaimer
+# in the documentation and/or other materials provided with the
+# distribution.
+# * Neither the name of Google Inc. nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import unittest
+
+from webkitpy.common.config.ports import WebKitPort, MacPort, GtkPort, QtPort, ChromiumPort
+
+
+class WebKitPortTest(unittest.TestCase):
+ def test_mac_port(self):
+ self.assertEquals(MacPort.name(), "Mac")
+ self.assertEquals(MacPort.flag(), "--port=mac")
+ self.assertEquals(MacPort.run_webkit_tests_command(), [WebKitPort.script_path("run-webkit-tests")])
+ self.assertEquals(MacPort.build_webkit_command(), [WebKitPort.script_path("build-webkit")])
+ self.assertEquals(MacPort.build_webkit_command(build_style="debug"), [WebKitPort.script_path("build-webkit"), "--debug"])
+ self.assertEquals(MacPort.build_webkit_command(build_style="release"), [WebKitPort.script_path("build-webkit"), "--release"])
+
+ def test_gtk_port(self):
+ self.assertEquals(GtkPort.name(), "Gtk")
+ self.assertEquals(GtkPort.flag(), "--port=gtk")
+ self.assertEquals(GtkPort.run_webkit_tests_command(), [WebKitPort.script_path("run-webkit-tests"), "--gtk"])
+ self.assertEquals(GtkPort.build_webkit_command(), [WebKitPort.script_path("build-webkit"), "--gtk", WebKitPort.makeArgs()])
+ self.assertEquals(GtkPort.build_webkit_command(build_style="debug"), [WebKitPort.script_path("build-webkit"), "--debug", "--gtk", WebKitPort.makeArgs()])
+
+ def test_qt_port(self):
+ self.assertEquals(QtPort.name(), "Qt")
+ self.assertEquals(QtPort.flag(), "--port=qt")
+ self.assertEquals(QtPort.run_webkit_tests_command(), [WebKitPort.script_path("run-webkit-tests")])
+ self.assertEquals(QtPort.build_webkit_command(), [WebKitPort.script_path("build-webkit"), "--qt", WebKitPort.makeArgs()])
+ self.assertEquals(QtPort.build_webkit_command(build_style="debug"), [WebKitPort.script_path("build-webkit"), "--debug", "--qt", WebKitPort.makeArgs()])
+
+ def test_chromium_port(self):
+ self.assertEquals(ChromiumPort.name(), "Chromium")
+ self.assertEquals(ChromiumPort.flag(), "--port=chromium")
+ self.assertEquals(ChromiumPort.run_webkit_tests_command(), [WebKitPort.script_path("run-webkit-tests")])
+ self.assertEquals(ChromiumPort.build_webkit_command(), [WebKitPort.script_path("build-webkit"), "--chromium"])
+ self.assertEquals(ChromiumPort.build_webkit_command(build_style="debug"), [WebKitPort.script_path("build-webkit"), "--debug", "--chromium"])
+ self.assertEquals(ChromiumPort.update_webkit_command(), [WebKitPort.script_path("update-webkit"), "--chromium"])
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/WebKitTools/Scripts/webkitpy/common/net/__init__.py b/WebKitTools/Scripts/webkitpy/common/net/__init__.py
new file mode 100644
index 0000000..ef65bee
--- /dev/null
+++ b/WebKitTools/Scripts/webkitpy/common/net/__init__.py
@@ -0,0 +1 @@
+# Required for Python to search this directory for module files
diff --git a/WebKitTools/Scripts/webkitpy/common/net/bugzilla.py b/WebKitTools/Scripts/webkitpy/common/net/bugzilla.py
new file mode 100644
index 0000000..6920d67
--- /dev/null
+++ b/WebKitTools/Scripts/webkitpy/common/net/bugzilla.py
@@ -0,0 +1,835 @@
+# Copyright (c) 2009 Google Inc. All rights reserved.
+# Copyright (c) 2009 Apple Inc. All rights reserved.
+# Copyright (c) 2010 Research In Motion Limited. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# * Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following disclaimer
+# in the documentation and/or other materials provided with the
+# distribution.
+# * Neither the name of Google Inc. nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+#
+# WebKit's Python module for interacting with Bugzilla
+
+import os.path
+import re
+import subprocess
+
+from datetime import datetime # used in timestamp()
+
+from webkitpy.common.system.deprecated_logging import error, log
+from webkitpy.common.config import committers
+from webkitpy.common.net.credentials import Credentials
+from webkitpy.common.system.ospath import relpath
+from webkitpy.common.system.user import User
+from webkitpy.thirdparty.autoinstalled.mechanize import Browser
+from webkitpy.thirdparty.BeautifulSoup import BeautifulSoup, SoupStrainer
+
+
+def parse_bug_id(message):
+ if not message:
+ return None
+ match = re.search("http\://webkit\.org/b/(?P<bug_id>\d+)", message)
+ if match:
+ return int(match.group('bug_id'))
+ match = re.search(
+ Bugzilla.bug_server_regex + "show_bug\.cgi\?id=(?P<bug_id>\d+)",
+ message)
+ if match:
+ return int(match.group('bug_id'))
+ return None
+
+
+def timestamp():
+ return datetime.now().strftime("%Y%m%d%H%M%S")
+
+
+class Attachment(object):
+
+ rollout_preamble = "ROLLOUT of r"
+
+ def __init__(self, attachment_dictionary, bug):
+ self._attachment_dictionary = attachment_dictionary
+ self._bug = bug
+ self._reviewer = None
+ self._committer = None
+
+ def _bugzilla(self):
+ return self._bug._bugzilla
+
+ def id(self):
+ return int(self._attachment_dictionary.get("id"))
+
+ def attacher_is_committer(self):
+ return self._bugzilla.committers.committer_by_email(
+ patch.attacher_email())
+
+ def attacher_email(self):
+ return self._attachment_dictionary.get("attacher_email")
+
+ def bug(self):
+ return self._bug
+
+ def bug_id(self):
+ return int(self._attachment_dictionary.get("bug_id"))
+
+ def is_patch(self):
+ return not not self._attachment_dictionary.get("is_patch")
+
+ def is_obsolete(self):
+ return not not self._attachment_dictionary.get("is_obsolete")
+
+ def is_rollout(self):
+ return self.name().startswith(self.rollout_preamble)
+
+ def name(self):
+ return self._attachment_dictionary.get("name")
+
+ def review(self):
+ return self._attachment_dictionary.get("review")
+
+ def commit_queue(self):
+ return self._attachment_dictionary.get("commit-queue")
+
+ def url(self):
+ # FIXME: This should just return
+ # self._bugzilla().attachment_url_for_id(self.id()). scm_unittest.py
+ # depends on the current behavior.
+ return self._attachment_dictionary.get("url")
+
+ def _validate_flag_value(self, flag):
+ email = self._attachment_dictionary.get("%s_email" % flag)
+ if not email:
+ return None
+ committer = getattr(self._bugzilla().committers,
+ "%s_by_email" % flag)(email)
+ if committer:
+ return committer
+ log("Warning, attachment %s on bug %s has invalid %s (%s)" % (
+ self._attachment_dictionary['id'],
+ self._attachment_dictionary['bug_id'], flag, email))
+
+ def reviewer(self):
+ if not self._reviewer:
+ self._reviewer = self._validate_flag_value("reviewer")
+ return self._reviewer
+
+ def committer(self):
+ if not self._committer:
+ self._committer = self._validate_flag_value("committer")
+ return self._committer
+
+
+class Bug(object):
+ # FIXME: This class is kinda a hack for now. It exists so we have one
+ # place to hold bug logic, even if much of the code deals with
+ # dictionaries still.
+
+ def __init__(self, bug_dictionary, bugzilla):
+ self.bug_dictionary = bug_dictionary
+ self._bugzilla = bugzilla
+
+ def id(self):
+ return self.bug_dictionary["id"]
+
+ def assigned_to_email(self):
+ return self.bug_dictionary["assigned_to_email"]
+
+ # FIXME: This information should be stored in some sort of webkit_config.py instead of here.
+ unassigned_emails = frozenset([
+ "webkit-unassigned@lists.webkit.org",
+ "webkit-qt-unassigned@trolltech.com",
+ ])
+ def is_unassigned(self):
+ return self.assigned_to_email() in self.unassigned_emails
+
+ # Rarely do we actually want obsolete attachments
+ def attachments(self, include_obsolete=False):
+ attachments = self.bug_dictionary["attachments"]
+ if not include_obsolete:
+ attachments = filter(lambda attachment:
+ not attachment["is_obsolete"], attachments)
+ return [Attachment(attachment, self) for attachment in attachments]
+
+ def patches(self, include_obsolete=False):
+ return [patch for patch in self.attachments(include_obsolete)
+ if patch.is_patch()]
+
+ def unreviewed_patches(self):
+ return [patch for patch in self.patches() if patch.review() == "?"]
+
+ def reviewed_patches(self, include_invalid=False):
+ patches = [patch for patch in self.patches() if patch.review() == "+"]
+ if include_invalid:
+ return patches
+ # Checking reviewer() ensures that it was both reviewed and has a valid
+ # reviewer.
+ return filter(lambda patch: patch.reviewer(), patches)
+
+ def commit_queued_patches(self, include_invalid=False):
+ patches = [patch for patch in self.patches()
+ if patch.commit_queue() == "+"]
+ if include_invalid:
+ return patches
+ # Checking committer() ensures that it was both commit-queue+'d and has
+ # a valid committer.
+ return filter(lambda patch: patch.committer(), patches)
+
+
+# A container for all of the logic for making and parsing buzilla queries.
+class BugzillaQueries(object):
+
+ def __init__(self, bugzilla):
+ self._bugzilla = bugzilla
+
+ # Note: _load_query and _fetch_bug are the only two methods which access
+ # self._bugzilla.
+
+ def _load_query(self, query):
+ self._bugzilla.authenticate()
+
+ full_url = "%s%s" % (self._bugzilla.bug_server_url, query)
+ return self._bugzilla.browser.open(full_url)
+
+ def _fetch_bug(self, bug_id):
+ return self._bugzilla.fetch_bug(bug_id)
+
+ def _fetch_bug_ids_advanced_query(self, query):
+ soup = BeautifulSoup(self._load_query(query))
+ # The contents of the <a> inside the cells in the first column happen
+ # to be the bug id.
+ return [int(bug_link_cell.find("a").string)
+ for bug_link_cell in soup('td', "first-child")]
+
+ def _parse_attachment_ids_request_query(self, page):
+ digits = re.compile("\d+")
+ attachment_href = re.compile("attachment.cgi\?id=\d+&action=review")
+ attachment_links = SoupStrainer("a", href=attachment_href)
+ return [int(digits.search(tag["href"]).group(0))
+ for tag in BeautifulSoup(page, parseOnlyThese=attachment_links)]
+
+ def _fetch_attachment_ids_request_query(self, query):
+ return self._parse_attachment_ids_request_query(self._load_query(query))
+
+ def _parse_quips(self, page):
+ soup = BeautifulSoup(page, convertEntities=BeautifulSoup.HTML_ENTITIES)
+ quips = soup.find(text=re.compile(r"Existing quips:")).findNext("ul").findAll("li")
+ return [unicode(quip_entry.string) for quip_entry in quips]
+
+ def fetch_quips(self):
+ return self._parse_quips(self._load_query("/quips.cgi?action=show"))
+
+ # List of all r+'d bugs.
+ def fetch_bug_ids_from_pending_commit_list(self):
+ 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"
+ return self._fetch_bug_ids_advanced_query(needs_commit_query_url)
+
+ def fetch_patches_from_pending_commit_list(self):
+ return sum([self._fetch_bug(bug_id).reviewed_patches()
+ for bug_id in self.fetch_bug_ids_from_pending_commit_list()], [])
+
+ def fetch_bug_ids_from_commit_queue(self):
+ 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"
+ return self._fetch_bug_ids_advanced_query(commit_queue_url)
+
+ def fetch_patches_from_commit_queue(self):
+ # This function will only return patches which have valid committers
+ # set. It won't reject patches with invalid committers/reviewers.
+ return sum([self._fetch_bug(bug_id).commit_queued_patches()
+ for bug_id in self.fetch_bug_ids_from_commit_queue()], [])
+
+ def _fetch_bug_ids_from_review_queue(self):
+ 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?"
+ return self._fetch_bug_ids_advanced_query(review_queue_url)
+
+ def fetch_patches_from_review_queue(self, limit=None):
+ # [:None] returns the whole array.
+ return sum([self._fetch_bug(bug_id).unreviewed_patches()
+ for bug_id in self._fetch_bug_ids_from_review_queue()[:limit]], [])
+
+ # FIXME: Why do we have both fetch_patches_from_review_queue and
+ # fetch_attachment_ids_from_review_queue??
+ # NOTE: This is also the only client of _fetch_attachment_ids_request_query
+
+ def fetch_attachment_ids_from_review_queue(self):
+ review_queue_url = "request.cgi?action=queue&type=review&group=type"
+ return self._fetch_attachment_ids_request_query(review_queue_url)
+
+
+class CommitterValidator(object):
+
+ def __init__(self, bugzilla):
+ self._bugzilla = bugzilla
+
+ # _view_source_url belongs in some sort of webkit_config.py module.
+ def _view_source_url(self, local_path):
+ return "http://trac.webkit.org/browser/trunk/%s" % local_path
+
+ def _checkout_root(self):
+ # FIXME: This is a hack, we would have this from scm.checkout_root
+ # if we had any way to get to an scm object here.
+ components = __file__.split(os.sep)
+ tools_index = components.index("WebKitTools")
+ return os.sep.join(components[:tools_index])
+
+ def _committers_py_path(self):
+ # extension can sometimes be .pyc, we always want .py
+ (path, extension) = os.path.splitext(committers.__file__)
+ # FIXME: When we're allowed to use python 2.6 we can use the real
+ # os.path.relpath
+ path = relpath(path, self._checkout_root())
+ return ".".join([path, "py"])
+
+ def _flag_permission_rejection_message(self, setter_email, flag_name):
+ # Should come from some webkit_config.py
+ contribution_guidlines = "http://webkit.org/coding/contributing.html"
+ # This could be queried from the status_server.
+ queue_administrator = "eseidel@chromium.org"
+ # This could be queried from the tool.
+ queue_name = "commit-queue"
+ committers_list = self._committers_py_path()
+ message = "%s does not have %s permissions according to %s." % (
+ setter_email,
+ flag_name,
+ self._view_source_url(committers_list))
+ 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)
+ 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, committers_list)
+ message += "Due to bug 30084 the %s will require a restart after your change. " % queue_name
+ message += "Please contact %s to request a %s restart. " % (
+ queue_administrator, queue_name)
+ message += "After restart the %s will correctly respect your %s rights." % (
+ queue_name, flag_name)
+ return message
+
+ def _validate_setter_email(self, patch, result_key, rejection_function):
+ committer = getattr(patch, result_key)()
+ # If the flag is set, and we don't recognize the setter, reject the
+ # flag!
+ setter_email = patch._attachment_dictionary.get("%s_email" % result_key)
+ if setter_email and not committer:
+ rejection_function(patch.id(),
+ self._flag_permission_rejection_message(setter_email,
+ result_key))
+ return False
+ return True
+
+ def patches_after_rejecting_invalid_commiters_and_reviewers(self, patches):
+ validated_patches = []
+ for patch in patches:
+ if (self._validate_setter_email(
+ patch, "reviewer", self.reject_patch_from_review_queue)
+ and self._validate_setter_email(
+ patch, "committer", self.reject_patch_from_commit_queue)):
+ validated_patches.append(patch)
+ return validated_patches
+
+ def reject_patch_from_commit_queue(self,
+ attachment_id,
+ additional_comment_text=None):
+ comment_text = "Rejecting patch %s from commit-queue." % attachment_id
+ self._bugzilla.set_flag_on_attachment(attachment_id,
+ "commit-queue",
+ "-",
+ comment_text,
+ additional_comment_text)
+
+ def reject_patch_from_review_queue(self,
+ attachment_id,
+ additional_comment_text=None):
+ comment_text = "Rejecting patch %s from review queue." % attachment_id
+ self._bugzilla.set_flag_on_attachment(attachment_id,
+ 'review',
+ '-',
+ comment_text,
+ additional_comment_text)
+
+
+class Bugzilla(object):
+
+ def __init__(self, dryrun=False, committers=committers.CommitterList()):
+ self.dryrun = dryrun
+ self.authenticated = False
+ self.queries = BugzillaQueries(self)
+ self.committers = committers
+ self.cached_quips = []
+
+ # FIXME: We should use some sort of Browser mock object when in dryrun
+ # mode (to prevent any mistakes).
+ self.browser = Browser()
+ # Ignore bugs.webkit.org/robots.txt until we fix it to allow this
+ # script.
+ self.browser.set_handle_robots(False)
+
+ # FIXME: Much of this should go into some sort of config module:
+ bug_server_host = "bugs.webkit.org"
+ bug_server_regex = "https?://%s/" % re.sub('\.', '\\.', bug_server_host)
+ bug_server_url = "https://%s/" % bug_server_host
+
+ def quips(self):
+ # We only fetch and parse the list of quips once per instantiation
+ # so that we do not burden bugs.webkit.org.
+ if not self.cached_quips and not self.dryrun:
+ self.cached_quips = self.queries.fetch_quips()
+ return self.cached_quips
+
+ def bug_url_for_bug_id(self, bug_id, xml=False):
+ if not bug_id:
+ return None
+ content_type = "&ctype=xml" if xml else ""
+ return "%sshow_bug.cgi?id=%s%s" % (self.bug_server_url,
+ bug_id,
+ content_type)
+
+ def short_bug_url_for_bug_id(self, bug_id):
+ if not bug_id:
+ return None
+ return "http://webkit.org/b/%s" % bug_id
+
+ def attachment_url_for_id(self, attachment_id, action="view"):
+ if not attachment_id:
+ return None
+ action_param = ""
+ if action and action != "view":
+ action_param = "&action=%s" % action
+ return "%sattachment.cgi?id=%s%s" % (self.bug_server_url,
+ attachment_id,
+ action_param)
+
+ def _parse_attachment_flag(self,
+ element,
+ flag_name,
+ attachment,
+ result_key):
+ flag = element.find('flag', attrs={'name': flag_name})
+ if flag:
+ attachment[flag_name] = flag['status']
+ if flag['status'] == '+':
+ attachment[result_key] = flag['setter']
+
+ def _parse_attachment_element(self, element, bug_id):
+ attachment = {}
+ attachment['bug_id'] = bug_id
+ attachment['is_obsolete'] = (element.has_key('isobsolete') and element['isobsolete'] == "1")
+ attachment['is_patch'] = (element.has_key('ispatch') and element['ispatch'] == "1")
+ attachment['id'] = int(element.find('attachid').string)
+ # FIXME: No need to parse out the url here.
+ attachment['url'] = self.attachment_url_for_id(attachment['id'])
+ attachment['name'] = unicode(element.find('desc').string)
+ attachment['attacher_email'] = str(element.find('attacher').string)
+ attachment['type'] = str(element.find('type').string)
+ self._parse_attachment_flag(
+ element, 'review', attachment, 'reviewer_email')
+ self._parse_attachment_flag(
+ element, 'commit-queue', attachment, 'committer_email')
+ return attachment
+
+ def _parse_bug_page(self, page):
+ soup = BeautifulSoup(page)
+ bug = {}
+ bug["id"] = int(soup.find("bug_id").string)
+ bug["title"] = unicode(soup.find("short_desc").string)
+ bug["reporter_email"] = str(soup.find("reporter").string)
+ bug["assigned_to_email"] = str(soup.find("assigned_to").string)
+ bug["cc_emails"] = [str(element.string)
+ for element in soup.findAll('cc')]
+ bug["attachments"] = [self._parse_attachment_element(element, bug["id"]) for element in soup.findAll('attachment')]
+ return bug
+
+ # Makes testing fetch_*_from_bug() possible until we have a better
+ # BugzillaNetwork abstration.
+
+ def _fetch_bug_page(self, bug_id):
+ bug_url = self.bug_url_for_bug_id(bug_id, xml=True)
+ log("Fetching: %s" % bug_url)
+ return self.browser.open(bug_url)
+
+ def fetch_bug_dictionary(self, bug_id):
+ try:
+ return self._parse_bug_page(self._fetch_bug_page(bug_id))
+ except:
+ self.authenticate()
+ return self._parse_bug_page(self._fetch_bug_page(bug_id))
+
+ # FIXME: A BugzillaCache object should provide all these fetch_ methods.
+
+ def fetch_bug(self, bug_id):
+ return Bug(self.fetch_bug_dictionary(bug_id), self)
+
+ def _parse_bug_id_from_attachment_page(self, page):
+ # The "Up" relation happens to point to the bug.
+ up_link = BeautifulSoup(page).find('link', rel='Up')
+ if not up_link:
+ # This attachment does not exist (or you don't have permissions to
+ # view it).
+ return None
+ match = re.search("show_bug.cgi\?id=(?P<bug_id>\d+)", up_link['href'])
+ return int(match.group('bug_id'))
+
+ def bug_id_for_attachment_id(self, attachment_id):
+ self.authenticate()
+
+ attachment_url = self.attachment_url_for_id(attachment_id, 'edit')
+ log("Fetching: %s" % attachment_url)
+ page = self.browser.open(attachment_url)
+ return self._parse_bug_id_from_attachment_page(page)
+
+ # FIXME: This should just return Attachment(id), which should be able to
+ # lazily fetch needed data.
+
+ def fetch_attachment(self, attachment_id):
+ # We could grab all the attachment details off of the attachment edit
+ # page but we already have working code to do so off of the bugs page,
+ # so re-use that.
+ bug_id = self.bug_id_for_attachment_id(attachment_id)
+ if not bug_id:
+ return None
+ attachments = self.fetch_bug(bug_id).attachments(include_obsolete=True)
+ for attachment in attachments:
+ if attachment.id() == int(attachment_id):
+ return attachment
+ return None # This should never be hit.
+
+ def authenticate(self):
+ if self.authenticated:
+ return
+
+ if self.dryrun:
+ log("Skipping log in for dry run...")
+ self.authenticated = True
+ return
+
+ attempts = 0
+ while not self.authenticated:
+ attempts += 1
+ (username, password) = Credentials(
+ self.bug_server_host, git_prefix="bugzilla").read_credentials()
+
+ log("Logging in as %s..." % username)
+ self.browser.open(self.bug_server_url +
+ "index.cgi?GoAheadAndLogIn=1")
+ self.browser.select_form(name="login")
+ self.browser['Bugzilla_login'] = username
+ self.browser['Bugzilla_password'] = password
+ response = self.browser.submit()
+
+ match = re.search("<title>(.+?)</title>", response.read())
+ # If the resulting page has a title, and it contains the word
+ # "invalid" assume it's the login failure page.
+ if match and re.search("Invalid", match.group(1), re.IGNORECASE):
+ errorMessage = "Bugzilla login failed: %s" % match.group(1)
+ # raise an exception only if this was the last attempt
+ if attempts < 5:
+ log(errorMessage)
+ else:
+ raise Exception(errorMessage)
+ else:
+ self.authenticated = True
+
+ def _fill_attachment_form(self,
+ description,
+ patch_file_object,
+ comment_text=None,
+ mark_for_review=False,
+ mark_for_commit_queue=False,
+ mark_for_landing=False, bug_id=None):
+ self.browser['description'] = description
+ self.browser['ispatch'] = ("1",)
+ self.browser['flag_type-1'] = ('?',) if mark_for_review else ('X',)
+
+ if mark_for_landing:
+ self.browser['flag_type-3'] = ('+',)
+ elif mark_for_commit_queue:
+ self.browser['flag_type-3'] = ('?',)
+ else:
+ self.browser['flag_type-3'] = ('X',)
+
+ if bug_id:
+ patch_name = "bug-%s-%s.patch" % (bug_id, timestamp())
+ else:
+ patch_name ="%s.patch" % timestamp()
+ self.browser.add_file(patch_file_object,
+ "text/plain",
+ patch_name,
+ 'data')
+
+ def add_patch_to_bug(self,
+ bug_id,
+ patch_file_object,
+ description,
+ comment_text=None,
+ mark_for_review=False,
+ mark_for_commit_queue=False,
+ mark_for_landing=False):
+ self.authenticate()
+
+ log('Adding patch "%s" to %sshow_bug.cgi?id=%s' % (description,
+ self.bug_server_url,
+ bug_id))
+
+ if self.dryrun:
+ log(comment_text)
+ return
+
+ self.browser.open("%sattachment.cgi?action=enter&bugid=%s" % (
+ self.bug_server_url, bug_id))
+ self.browser.select_form(name="entryform")
+ self._fill_attachment_form(description,
+ patch_file_object,
+ mark_for_review=mark_for_review,
+ mark_for_commit_queue=mark_for_commit_queue,
+ mark_for_landing=mark_for_landing,
+ bug_id=bug_id)
+ if comment_text:
+ log(comment_text)
+ self.browser['comment'] = comment_text
+ self.browser.submit()
+
+ def _check_create_bug_response(self, response_html):
+ match = re.search("<title>Bug (?P<bug_id>\d+) Submitted</title>",
+ response_html)
+ if match:
+ return match.group('bug_id')
+
+ match = re.search(
+ '<div id="bugzilla-body">(?P<error_message>.+)<div id="footer">',
+ response_html,
+ re.DOTALL)
+ error_message = "FAIL"
+ if match:
+ text_lines = BeautifulSoup(
+ match.group('error_message')).findAll(text=True)
+ error_message = "\n" + '\n'.join(
+ [" " + line.strip()
+ for line in text_lines if line.strip()])
+ raise Exception("Bug not created: %s" % error_message)
+
+ def create_bug(self,
+ bug_title,
+ bug_description,
+ component=None,
+ patch_file_object=None,
+ patch_description=None,
+ cc=None,
+ blocked=None,
+ mark_for_review=False,
+ mark_for_commit_queue=False):
+ self.authenticate()
+
+ log('Creating bug with title "%s"' % bug_title)
+ if self.dryrun:
+ log(bug_description)
+ return
+
+ self.browser.open(self.bug_server_url + "enter_bug.cgi?product=WebKit")
+ self.browser.select_form(name="Create")
+ component_items = self.browser.find_control('component').items
+ component_names = map(lambda item: item.name, component_items)
+ if not component:
+ component = "New Bugs"
+ if component not in component_names:
+ component = User.prompt_with_list("Please pick a component:", component_names)
+ self.browser["component"] = [component]
+ if cc:
+ self.browser["cc"] = cc
+ if blocked:
+ self.browser["blocked"] = str(blocked)
+ self.browser["short_desc"] = bug_title
+ self.browser["comment"] = bug_description
+
+ if patch_file_object:
+ self._fill_attachment_form(
+ patch_description,
+ patch_file_object,
+ mark_for_review=mark_for_review,
+ mark_for_commit_queue=mark_for_commit_queue)
+
+ response = self.browser.submit()
+
+ bug_id = self._check_create_bug_response(response.read())
+ log("Bug %s created." % bug_id)
+ log("%sshow_bug.cgi?id=%s" % (self.bug_server_url, bug_id))
+ return bug_id
+
+ def _find_select_element_for_flag(self, flag_name):
+ # FIXME: This will break if we ever re-order attachment flags
+ if flag_name == "review":
+ return self.browser.find_control(type='select', nr=0)
+ if flag_name == "commit-queue":
+ return self.browser.find_control(type='select', nr=1)
+ raise Exception("Don't know how to find flag named \"%s\"" % flag_name)
+
+ def clear_attachment_flags(self,
+ attachment_id,
+ additional_comment_text=None):
+ self.authenticate()
+
+ comment_text = "Clearing flags on attachment: %s" % attachment_id
+ if additional_comment_text:
+ comment_text += "\n\n%s" % additional_comment_text
+ log(comment_text)
+
+ if self.dryrun:
+ return
+
+ self.browser.open(self.attachment_url_for_id(attachment_id, 'edit'))
+ self.browser.select_form(nr=1)
+ self.browser.set_value(comment_text, name='comment', nr=0)
+ self._find_select_element_for_flag('review').value = ("X",)
+ self._find_select_element_for_flag('commit-queue').value = ("X",)
+ self.browser.submit()
+
+ def set_flag_on_attachment(self,
+ attachment_id,
+ flag_name,
+ flag_value,
+ comment_text,
+ additional_comment_text):
+ # FIXME: We need a way to test this function on a live bugzilla
+ # instance.
+
+ self.authenticate()
+
+ if additional_comment_text:
+ comment_text += "\n\n%s" % additional_comment_text
+ log(comment_text)
+
+ if self.dryrun:
+ return
+
+ self.browser.open(self.attachment_url_for_id(attachment_id, 'edit'))
+ self.browser.select_form(nr=1)
+ self.browser.set_value(comment_text, name='comment', nr=0)
+ self._find_select_element_for_flag(flag_name).value = (flag_value,)
+ self.browser.submit()
+
+ # FIXME: All of these bug editing methods have a ridiculous amount of
+ # copy/paste code.
+
+ def obsolete_attachment(self, attachment_id, comment_text=None):
+ self.authenticate()
+
+ log("Obsoleting attachment: %s" % attachment_id)
+ if self.dryrun:
+ log(comment_text)
+ return
+
+ self.browser.open(self.attachment_url_for_id(attachment_id, 'edit'))
+ self.browser.select_form(nr=1)
+ self.browser.find_control('isobsolete').items[0].selected = True
+ # Also clear any review flag (to remove it from review/commit queues)
+ self._find_select_element_for_flag('review').value = ("X",)
+ self._find_select_element_for_flag('commit-queue').value = ("X",)
+ if comment_text:
+ log(comment_text)
+ # Bugzilla has two textareas named 'comment', one is somehow
+ # hidden. We want the first.
+ self.browser.set_value(comment_text, name='comment', nr=0)
+ self.browser.submit()
+
+ def add_cc_to_bug(self, bug_id, email_address_list):
+ self.authenticate()
+
+ log("Adding %s to the CC list for bug %s" % (email_address_list,
+ bug_id))
+ if self.dryrun:
+ return
+
+ self.browser.open(self.bug_url_for_bug_id(bug_id))
+ self.browser.select_form(name="changeform")
+ self.browser["newcc"] = ", ".join(email_address_list)
+ self.browser.submit()
+
+ def post_comment_to_bug(self, bug_id, comment_text, cc=None):
+ self.authenticate()
+
+ log("Adding comment to bug %s" % bug_id)
+ if self.dryrun:
+ log(comment_text)
+ return
+
+ self.browser.open(self.bug_url_for_bug_id(bug_id))
+ self.browser.select_form(name="changeform")
+ self.browser["comment"] = comment_text
+ if cc:
+ self.browser["newcc"] = ", ".join(cc)
+ self.browser.submit()
+
+ def close_bug_as_fixed(self, bug_id, comment_text=None):
+ self.authenticate()
+
+ log("Closing bug %s as fixed" % bug_id)
+ if self.dryrun:
+ log(comment_text)
+ return
+
+ self.browser.open(self.bug_url_for_bug_id(bug_id))
+ self.browser.select_form(name="changeform")
+ if comment_text:
+ log(comment_text)
+ self.browser['comment'] = comment_text
+ self.browser['bug_status'] = ['RESOLVED']
+ self.browser['resolution'] = ['FIXED']
+ self.browser.submit()
+
+ def reassign_bug(self, bug_id, assignee, comment_text=None):
+ self.authenticate()
+
+ log("Assigning bug %s to %s" % (bug_id, assignee))
+ if self.dryrun:
+ log(comment_text)
+ return
+
+ self.browser.open(self.bug_url_for_bug_id(bug_id))
+ self.browser.select_form(name="changeform")
+ if comment_text:
+ log(comment_text)
+ self.browser["comment"] = comment_text
+ self.browser["assigned_to"] = assignee
+ self.browser.submit()
+
+ def reopen_bug(self, bug_id, comment_text):
+ self.authenticate()
+
+ log("Re-opening bug %s" % bug_id)
+ # Bugzilla requires a comment when re-opening a bug, so we know it will
+ # never be None.
+ log(comment_text)
+ if self.dryrun:
+ return
+
+ self.browser.open(self.bug_url_for_bug_id(bug_id))
+ self.browser.select_form(name="changeform")
+ bug_status = self.browser.find_control("bug_status", type="select")
+ # This is a hack around the fact that ClientForm.ListControl seems to
+ # have no simpler way to ask if a control has an item named "REOPENED"
+ # without using exceptions for control flow.
+ possible_bug_statuses = map(lambda item: item.name, bug_status.items)
+ if "REOPENED" in possible_bug_statuses:
+ bug_status.value = ["REOPENED"]
+ else:
+ log("Did not reopen bug %s. " +
+ "It appears to already be open with status %s." % (
+ bug_id, bug_status.value))
+ self.browser['comment'] = comment_text
+ self.browser.submit()
diff --git a/WebKitTools/Scripts/webkitpy/common/net/bugzilla_unittest.py b/WebKitTools/Scripts/webkitpy/common/net/bugzilla_unittest.py
new file mode 100644
index 0000000..4c44cdf
--- /dev/null
+++ b/WebKitTools/Scripts/webkitpy/common/net/bugzilla_unittest.py
@@ -0,0 +1,347 @@
+# Copyright (C) 2009 Google Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# * Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following disclaimer
+# in the documentation and/or other materials provided with the
+# distribution.
+# * Neither the name of Google Inc. nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import unittest
+
+from webkitpy.common.config.committers import CommitterList, Reviewer, Committer
+from webkitpy.common.net.bugzilla import Bugzilla, BugzillaQueries, parse_bug_id, CommitterValidator, Bug
+from webkitpy.common.system.outputcapture import OutputCapture
+from webkitpy.thirdparty.mock import Mock
+from webkitpy.thirdparty.BeautifulSoup import BeautifulSoup
+
+
+class MockBrowser(object):
+ def open(self, url):
+ pass
+
+ def select_form(self, name):
+ pass
+
+ def __setitem__(self, key, value):
+ pass
+
+ def submit(self):
+ pass
+
+
+class BugTest(unittest.TestCase):
+ def test_is_unassigned(self):
+ for email in Bug.unassigned_emails:
+ bug = Bug({"assigned_to_email" : email}, bugzilla=None)
+ self.assertTrue(bug.is_unassigned())
+ bug = Bug({"assigned_to_email" : "test@test.com"}, bugzilla=None)
+ self.assertFalse(bug.is_unassigned())
+
+
+class CommitterValidatorTest(unittest.TestCase):
+ def test_flag_permission_rejection_message(self):
+ validator = CommitterValidator(bugzilla=None)
+ self.assertEqual(validator._committers_py_path(), "WebKitTools/Scripts/webkitpy/common/config/committers.py")
+ expected_messsage="""foo@foo.com does not have review permissions according to http://trac.webkit.org/browser/trunk/WebKitTools/Scripts/webkitpy/common/config/committers.py.
+
+- If you do not have review rights please read http://webkit.org/coding/contributing.html for instructions on how to use bugzilla flags.
+
+- If you have review rights please correct the error in WebKitTools/Scripts/webkitpy/common/config/committers.py by adding yourself to the file (no review needed). Due to bug 30084 the commit-queue will require a restart after your change. Please contact eseidel@chromium.org to request a commit-queue restart. After restart the commit-queue will correctly respect your review rights."""
+ self.assertEqual(validator._flag_permission_rejection_message("foo@foo.com", "review"), expected_messsage)
+
+
+class BugzillaTest(unittest.TestCase):
+ _example_attachment = '''
+ <attachment
+ isobsolete="1"
+ ispatch="1"
+ isprivate="0"
+ >
+ <attachid>33721</attachid>
+ <date>2009-07-29 10:23 PDT</date>
+ <desc>Fixed whitespace issue</desc>
+ <filename>patch</filename>
+ <type>text/plain</type>
+ <size>9719</size>
+ <attacher>christian.plesner.hansen@gmail.com</attacher>
+ <flag name="review"
+ id="17931"
+ status="+"
+ setter="one@test.com"
+ />
+ <flag name="commit-queue"
+ id="17932"
+ status="+"
+ setter="two@test.com"
+ />
+ </attachment>
+'''
+ _expected_example_attachment_parsing = {
+ 'bug_id' : 100,
+ 'is_obsolete' : True,
+ 'is_patch' : True,
+ 'id' : 33721,
+ 'url' : "https://bugs.webkit.org/attachment.cgi?id=33721",
+ 'name' : "Fixed whitespace issue",
+ 'type' : "text/plain",
+ 'review' : '+',
+ 'reviewer_email' : 'one@test.com',
+ 'commit-queue' : '+',
+ 'committer_email' : 'two@test.com',
+ 'attacher_email' : 'christian.plesner.hansen@gmail.com',
+ }
+
+ def test_url_creation(self):
+ # FIXME: These would be all better as doctests
+ bugs = Bugzilla()
+ self.assertEquals(None, bugs.bug_url_for_bug_id(None))
+ self.assertEquals(None, bugs.short_bug_url_for_bug_id(None))
+ self.assertEquals(None, bugs.attachment_url_for_id(None))
+
+ def test_parse_bug_id(self):
+ # FIXME: These would be all better as doctests
+ bugs = Bugzilla()
+ self.assertEquals(12345, parse_bug_id("http://webkit.org/b/12345"))
+ self.assertEquals(12345, parse_bug_id("http://bugs.webkit.org/show_bug.cgi?id=12345"))
+ self.assertEquals(12345, parse_bug_id(bugs.short_bug_url_for_bug_id(12345)))
+ self.assertEquals(12345, parse_bug_id(bugs.bug_url_for_bug_id(12345)))
+ self.assertEquals(12345, parse_bug_id(bugs.bug_url_for_bug_id(12345, xml=True)))
+
+ # Our bug parser is super-fragile, but at least we're testing it.
+ self.assertEquals(None, parse_bug_id("http://www.webkit.org/b/12345"))
+ self.assertEquals(None, parse_bug_id("http://bugs.webkit.org/show_bug.cgi?ctype=xml&id=12345"))
+
+ _example_bug = """
+<?xml version="1.0" encoding="UTF-8" standalone="yes" ?>
+<!DOCTYPE bugzilla SYSTEM "https://bugs.webkit.org/bugzilla.dtd">
+<bugzilla version="3.2.3"
+ urlbase="https://bugs.webkit.org/"
+ maintainer="admin@webkit.org"
+ exporter="eric@webkit.org"
+>
+ <bug>
+ <bug_id>32585</bug_id>
+ <creation_ts>2009-12-15 15:17 PST</creation_ts>
+ <short_desc>bug to test webkit-patch and commit-queue failures</short_desc>
+ <delta_ts>2009-12-27 21:04:50 PST</delta_ts>
+ <reporter_accessible>1</reporter_accessible>
+ <cclist_accessible>1</cclist_accessible>
+ <classification_id>1</classification_id>
+ <classification>Unclassified</classification>
+ <product>WebKit</product>
+ <component>Tools / Tests</component>
+ <version>528+ (Nightly build)</version>
+ <rep_platform>PC</rep_platform>
+ <op_sys>Mac OS X 10.5</op_sys>
+ <bug_status>NEW</bug_status>
+ <priority>P2</priority>
+ <bug_severity>Normal</bug_severity>
+ <target_milestone>---</target_milestone>
+ <everconfirmed>1</everconfirmed>
+ <reporter name="Eric Seidel">eric@webkit.org</reporter>
+ <assigned_to name="Nobody">webkit-unassigned@lists.webkit.org</assigned_to>
+ <cc>foo@bar.com</cc>
+ <cc>example@example.com</cc>
+ <long_desc isprivate="0">
+ <who name="Eric Seidel">eric@webkit.org</who>
+ <bug_when>2009-12-15 15:17:28 PST</bug_when>
+ <thetext>bug to test webkit-patch and commit-queue failures
+
+Ignore this bug. Just for testing failure modes of webkit-patch and the commit-queue.</thetext>
+ </long_desc>
+ <attachment
+ isobsolete="0"
+ ispatch="1"
+ isprivate="0"
+ >
+ <attachid>45548</attachid>
+ <date>2009-12-27 23:51 PST</date>
+ <desc>Patch</desc>
+ <filename>bug-32585-20091228005112.patch</filename>
+ <type>text/plain</type>
+ <size>10882</size>
+ <attacher>mjs@apple.com</attacher>
+
+ <token>1261988248-dc51409e9c421a4358f365fa8bec8357</token>
+ <data encoding="base64">SW5kZXg6IFdlYktpdC9tYWMvQ2hhbmdlTG9nCj09PT09PT09PT09PT09PT09PT09PT09PT09PT09
+removed-because-it-was-really-long
+ZEZpbmlzaExvYWRXaXRoUmVhc29uOnJlYXNvbl07Cit9CisKIEBlbmQKIAogI2VuZGlmCg==
+</data>
+
+ <flag name="review"
+ id="27602"
+ status="?"
+ setter="mjs@apple.com"
+ />
+ </attachment>
+ </bug>
+</bugzilla>
+"""
+ _expected_example_bug_parsing = {
+ "id" : 32585,
+ "title" : u"bug to test webkit-patch and commit-queue failures",
+ "cc_emails" : ["foo@bar.com", "example@example.com"],
+ "reporter_email" : "eric@webkit.org",
+ "assigned_to_email" : "webkit-unassigned@lists.webkit.org",
+ "attachments" : [{
+ 'name': u'Patch',
+ 'url' : "https://bugs.webkit.org/attachment.cgi?id=45548",
+ 'is_obsolete': False,
+ 'review': '?',
+ 'is_patch': True,
+ 'attacher_email': 'mjs@apple.com',
+ 'bug_id': 32585,
+ 'type': 'text/plain',
+ 'id': 45548
+ }],
+ }
+
+ # FIXME: This should move to a central location and be shared by more unit tests.
+ def _assert_dictionaries_equal(self, actual, expected):
+ # Make sure we aren't parsing more or less than we expect
+ self.assertEquals(sorted(actual.keys()), sorted(expected.keys()))
+
+ for key, expected_value in expected.items():
+ self.assertEquals(actual[key], expected_value, ("Failure for key: %s: Actual='%s' Expected='%s'" % (key, actual[key], expected_value)))
+
+ def test_bug_parsing(self):
+ bug = Bugzilla()._parse_bug_page(self._example_bug)
+ self._assert_dictionaries_equal(bug, self._expected_example_bug_parsing)
+
+ # This could be combined into test_bug_parsing later if desired.
+ def test_attachment_parsing(self):
+ bugzilla = Bugzilla()
+ soup = BeautifulSoup(self._example_attachment)
+ attachment_element = soup.find("attachment")
+ attachment = bugzilla._parse_attachment_element(attachment_element, self._expected_example_attachment_parsing['bug_id'])
+ self.assertTrue(attachment)
+ self._assert_dictionaries_equal(attachment, self._expected_example_attachment_parsing)
+
+ _sample_attachment_detail_page = """
+<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"
+ "http://www.w3.org/TR/html4/loose.dtd">
+<html>
+ <head>
+ <title>
+ Attachment 41073 Details for Bug 27314</title>
+<link rel="Top" href="https://bugs.webkit.org/">
+ <link rel="Up" href="show_bug.cgi?id=27314">
+"""
+
+ def test_attachment_detail_bug_parsing(self):
+ bugzilla = Bugzilla()
+ self.assertEquals(27314, bugzilla._parse_bug_id_from_attachment_page(self._sample_attachment_detail_page))
+
+ def test_add_cc_to_bug(self):
+ bugzilla = Bugzilla()
+ bugzilla.browser = MockBrowser()
+ bugzilla.authenticate = lambda: None
+ expected_stderr = "Adding ['adam@example.com'] to the CC list for bug 42\n"
+ OutputCapture().assert_outputs(self, bugzilla.add_cc_to_bug, [42, ["adam@example.com"]], expected_stderr=expected_stderr)
+
+
+class BugzillaQueriesTest(unittest.TestCase):
+ _sample_request_page = """
+<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"
+ "http://www.w3.org/TR/html4/loose.dtd">
+<html>
+ <head>
+ <title>Request Queue</title>
+ </head>
+<body>
+
+<h3>Flag: review</h3>
+ <table class="requests" cellspacing="0" cellpadding="4" border="1">
+ <tr>
+ <th>Requester</th>
+ <th>Requestee</th>
+ <th>Bug</th>
+ <th>Attachment</th>
+ <th>Created</th>
+ </tr>
+ <tr>
+ <td>Shinichiro Hamaji &lt;hamaji&#64;chromium.org&gt;</td>
+ <td></td>
+ <td><a href="show_bug.cgi?id=30015">30015: text-transform:capitalize is failing in CSS2.1 test suite</a></td>
+ <td><a href="attachment.cgi?id=40511&amp;action=review">
+40511: Patch v0</a></td>
+ <td>2009-10-02 04:58 PST</td>
+ </tr>
+ <tr>
+ <td>Zan Dobersek &lt;zandobersek&#64;gmail.com&gt;</td>
+ <td></td>
+ <td><a href="show_bug.cgi?id=26304">26304: [GTK] Add controls for playing html5 video.</a></td>
+ <td><a href="attachment.cgi?id=40722&amp;action=review">
+40722: Media controls, the simple approach</a></td>
+ <td>2009-10-06 09:13 PST</td>
+ </tr>
+ <tr>
+ <td>Zan Dobersek &lt;zandobersek&#64;gmail.com&gt;</td>
+ <td></td>
+ <td><a href="show_bug.cgi?id=26304">26304: [GTK] Add controls for playing html5 video.</a></td>
+ <td><a href="attachment.cgi?id=40723&amp;action=review">
+40723: Adjust the media slider thumb size</a></td>
+ <td>2009-10-06 09:15 PST</td>
+ </tr>
+ </table>
+</body>
+</html>
+"""
+ _sample_quip_page = u"""
+<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"
+ "http://www.w3.org/TR/html4/loose.dtd">
+<html>
+ <head>
+ <title>Bugzilla Quip System</title>
+ </head>
+ <body>
+ <h2>
+
+ Existing quips:
+ </h2>
+ <ul>
+ <li>Everything should be made as simple as possible, but not simpler. - Albert Einstein</li>
+ <li>Good artists copy. Great artists steal. - Pablo Picasso</li>
+ <li>\u00e7gua mole em pedra dura, tanto bate at\u008e que fura.</li>
+
+ </ul>
+ </body>
+</html>
+"""
+
+ def test_request_page_parsing(self):
+ queries = BugzillaQueries(None)
+ self.assertEquals([40511, 40722, 40723], queries._parse_attachment_ids_request_query(self._sample_request_page))
+
+ def test_quip_page_parsing(self):
+ queries = BugzillaQueries(None)
+ expected_quips = ["Everything should be made as simple as possible, but not simpler. - Albert Einstein", "Good artists copy. Great artists steal. - Pablo Picasso", u"\u00e7gua mole em pedra dura, tanto bate at\u008e que fura."]
+ self.assertEquals(expected_quips, queries._parse_quips(self._sample_quip_page))
+
+ def test_load_query(self):
+ queries = BugzillaQueries(Mock())
+ queries._load_query("request.cgi?action=queue&type=review&group=type")
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/WebKitTools/Scripts/webkitpy/common/net/buildbot.py b/WebKitTools/Scripts/webkitpy/common/net/buildbot.py
new file mode 100644
index 0000000..753e909
--- /dev/null
+++ b/WebKitTools/Scripts/webkitpy/common/net/buildbot.py
@@ -0,0 +1,495 @@
+# Copyright (c) 2009, Google Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# * Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following disclaimer
+# in the documentation and/or other materials provided with the
+# distribution.
+# * Neither the name of Google Inc. nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+#
+# WebKit's Python module for interacting with WebKit's buildbot
+
+import operator
+import re
+import urllib
+import urllib2
+import xmlrpclib
+
+from webkitpy.common.system.logutils import get_logger
+from webkitpy.thirdparty.autoinstalled.mechanize import Browser
+from webkitpy.thirdparty.BeautifulSoup import BeautifulSoup
+
+
+_log = get_logger(__file__)
+
+
+class Builder(object):
+ def __init__(self, name, buildbot):
+ self._name = unicode(name)
+ self._buildbot = buildbot
+ self._builds_cache = {}
+ self._revision_to_build_number = None
+ self._browser = Browser()
+ self._browser.set_handle_robots(False) # The builder pages are excluded by robots.txt
+
+ def name(self):
+ return self._name
+
+ def results_url(self):
+ return "http://%s/results/%s" % (self._buildbot.buildbot_host, self.url_encoded_name())
+
+ def url_encoded_name(self):
+ return urllib.quote(self._name)
+
+ def url(self):
+ return "http://%s/builders/%s" % (self._buildbot.buildbot_host, self.url_encoded_name())
+
+ # This provides a single place to mock
+ def _fetch_build(self, build_number):
+ build_dictionary = self._buildbot._fetch_xmlrpc_build_dictionary(self, build_number)
+ if not build_dictionary:
+ return None
+ return Build(self,
+ build_number=int(build_dictionary['number']),
+ revision=int(build_dictionary['revision']),
+ is_green=(build_dictionary['results'] == 0) # Undocumented, buildbot XMLRPC, 0 seems to mean "pass"
+ )
+
+ def build(self, build_number):
+ if not build_number:
+ return None
+ cached_build = self._builds_cache.get(build_number)
+ if cached_build:
+ return cached_build
+
+ build = self._fetch_build(build_number)
+ self._builds_cache[build_number] = build
+ return build
+
+ def force_build(self, username="webkit-patch", comments=None):
+ def predicate(form):
+ try:
+ return form.find_control("username")
+ except Exception, e:
+ return False
+ self._browser.open(self.url())
+ self._browser.select_form(predicate=predicate)
+ self._browser["username"] = username
+ if comments:
+ self._browser["comments"] = comments
+ return self._browser.submit()
+
+ file_name_regexp = re.compile(r"r(?P<revision>\d+) \((?P<build_number>\d+)\)")
+ def _revision_and_build_for_filename(self, filename):
+ # Example: "r47483 (1)/" or "r47483 (1).zip"
+ match = self.file_name_regexp.match(filename)
+ return (int(match.group("revision")), int(match.group("build_number")))
+
+ def _fetch_revision_to_build_map(self):
+ # All _fetch requests go through _buildbot for easier mocking
+ try:
+ # FIXME: This method is horribly slow due to the huge network load.
+ # FIXME: This is a poor way to do revision -> build mapping.
+ # Better would be to ask buildbot through some sort of API.
+ print "Loading revision/build list from %s." % self.results_url()
+ print "This may take a while..."
+ result_files = self._buildbot._fetch_twisted_directory_listing(self.results_url())
+ except urllib2.HTTPError, error:
+ if error.code != 404:
+ raise
+ result_files = []
+
+ # This assumes there was only one build per revision, which is false but we don't care for now.
+ return dict([self._revision_and_build_for_filename(file_info["filename"]) for file_info in result_files])
+
+ def _revision_to_build_map(self):
+ if not self._revision_to_build_number:
+ self._revision_to_build_number = self._fetch_revision_to_build_map()
+ return self._revision_to_build_number
+
+ def revision_build_pairs_with_results(self):
+ return self._revision_to_build_map().items()
+
+ # This assumes there can be only one build per revision, which is false, but we don't care for now.
+ def build_for_revision(self, revision, allow_failed_lookups=False):
+ # NOTE: This lookup will fail if that exact revision was never built.
+ build_number = self._revision_to_build_map().get(int(revision))
+ if not build_number:
+ return None
+ build = self.build(build_number)
+ if not build and allow_failed_lookups:
+ # Builds for old revisions with fail to lookup via buildbot's xmlrpc api.
+ build = Build(self,
+ build_number=build_number,
+ revision=revision,
+ is_green=False,
+ )
+ return build
+
+ def find_failure_transition(self, red_build, look_back_limit=30):
+ if not red_build or red_build.is_green():
+ return (None, None)
+ common_failures = None
+ current_build = red_build
+ build_after_current_build = None
+ look_back_count = 0
+ while current_build:
+ if current_build.is_green():
+ # current_build can't possibly have any failures in common
+ # with red_build because it's green.
+ break
+ results = current_build.layout_test_results()
+ # We treat a lack of results as if all the test failed.
+ # This occurs, for example, when we can't compile at all.
+ if results:
+ failures = set(results.failing_tests())
+ if common_failures == None:
+ common_failures = failures
+ common_failures = common_failures.intersection(failures)
+ if not common_failures:
+ # current_build doesn't have any failures in common with
+ # the red build we're worried about. We assume that any
+ # failures in current_build were due to flakiness.
+ break
+ look_back_count += 1
+ if look_back_count > look_back_limit:
+ return (None, current_build)
+ build_after_current_build = current_build
+ current_build = current_build.previous_build()
+ # We must iterate at least once because red_build is red.
+ assert(build_after_current_build)
+ # Current build must either be green or have no failures in common
+ # with red build, so we've found our failure transition.
+ return (current_build, build_after_current_build)
+
+ # FIXME: This likely does not belong on Builder
+ def suspect_revisions_for_transition(self, last_good_build, first_bad_build):
+ suspect_revisions = range(first_bad_build.revision(),
+ last_good_build.revision(),
+ -1)
+ suspect_revisions.reverse()
+ return suspect_revisions
+
+ def blameworthy_revisions(self, red_build_number, look_back_limit=30, avoid_flakey_tests=True):
+ red_build = self.build(red_build_number)
+ (last_good_build, first_bad_build) = \
+ self.find_failure_transition(red_build, look_back_limit)
+ if not last_good_build:
+ return [] # We ran off the limit of our search
+ # If avoid_flakey_tests, require at least 2 bad builds before we
+ # suspect a real failure transition.
+ if avoid_flakey_tests and first_bad_build == red_build:
+ return []
+ return self.suspect_revisions_for_transition(last_good_build, first_bad_build)
+
+
+# FIXME: This should be unified with all the layout test results code in the layout_tests package
+class LayoutTestResults(object):
+ stderr_key = u'Tests that had stderr output:'
+ fail_key = u'Tests where results did not match expected results:'
+ timeout_key = u'Tests that timed out:'
+ crash_key = u'Tests that caused the DumpRenderTree tool to crash:'
+ missing_key = u'Tests that had no expected results (probably new):'
+
+ expected_keys = [
+ stderr_key,
+ fail_key,
+ crash_key,
+ timeout_key,
+ missing_key,
+ ]
+
+ @classmethod
+ def _parse_results_html(cls, page):
+ parsed_results = {}
+ tables = BeautifulSoup(page).findAll("table")
+ for table in tables:
+ table_title = table.findPreviousSibling("p").string
+ if table_title not in cls.expected_keys:
+ # This Exception should only ever be hit if run-webkit-tests changes its results.html format.
+ raise Exception("Unhandled title: %s" % str(table_title))
+ # We might want to translate table titles into identifiers before storing.
+ parsed_results[table_title] = [row.find("a").string for row in table.findAll("tr")]
+
+ return parsed_results
+
+ @classmethod
+ def _fetch_results_html(cls, base_url):
+ results_html = "%s/results.html" % base_url
+ # FIXME: We need to move this sort of 404 logic into NetworkTransaction or similar.
+ try:
+ page = urllib2.urlopen(results_html)
+ return cls._parse_results_html(page)
+ except urllib2.HTTPError, error:
+ if error.code != 404:
+ raise
+
+ @classmethod
+ def results_from_url(cls, base_url):
+ parsed_results = cls._fetch_results_html(base_url)
+ if not parsed_results:
+ return None
+ return cls(base_url, parsed_results)
+
+ def __init__(self, base_url, parsed_results):
+ self._base_url = base_url
+ self._parsed_results = parsed_results
+
+ def parsed_results(self):
+ return self._parsed_results
+
+ def failing_tests(self):
+ failing_keys = [self.fail_key, self.crash_key, self.timeout_key]
+ return sorted(sum([tests for key, tests in self._parsed_results.items() if key in failing_keys], []))
+
+
+class Build(object):
+ def __init__(self, builder, build_number, revision, is_green):
+ self._builder = builder
+ self._number = build_number
+ self._revision = revision
+ self._is_green = is_green
+ self._layout_test_results = None
+
+ @staticmethod
+ def build_url(builder, build_number):
+ return "%s/builds/%s" % (builder.url(), build_number)
+
+ def url(self):
+ return self.build_url(self.builder(), self._number)
+
+ def results_url(self):
+ results_directory = "r%s (%s)" % (self.revision(), self._number)
+ return "%s/%s" % (self._builder.results_url(), urllib.quote(results_directory))
+
+ def layout_test_results(self):
+ if not self._layout_test_results:
+ self._layout_test_results = LayoutTestResults.results_from_url(self.results_url())
+ return self._layout_test_results
+
+ def builder(self):
+ return self._builder
+
+ def revision(self):
+ return self._revision
+
+ def is_green(self):
+ return self._is_green
+
+ def previous_build(self):
+ # previous_build() allows callers to avoid assuming build numbers are sequential.
+ # They may not be sequential across all master changes, or when non-trunk builds are made.
+ return self._builder.build(self._number - 1)
+
+
+class BuildBot(object):
+ # FIXME: This should move into some sort of webkit_config.py
+ default_host = "build.webkit.org"
+
+ def __init__(self, host=default_host):
+ self.buildbot_host = host
+ self._builder_by_name = {}
+
+ # If any core builder is red we should not be landing patches. Other
+ # builders should be added to this list once they are known to be
+ # reliable.
+ # See https://bugs.webkit.org/show_bug.cgi?id=33296 and related bugs.
+ self.core_builder_names_regexps = [
+ "SnowLeopard.*Build",
+ "SnowLeopard.*Test",
+ "Leopard",
+ "Tiger",
+ "Windows.*Build",
+ "Windows.*Debug.*Test",
+ "GTK",
+ "Qt",
+ "Chromium",
+ ]
+
+ def _parse_last_build_cell(self, builder, cell):
+ status_link = cell.find('a')
+ if status_link:
+ # Will be either a revision number or a build number
+ revision_string = status_link.string
+ # If revision_string has non-digits assume it's not a revision number.
+ builder['built_revision'] = int(revision_string) \
+ if not re.match('\D', revision_string) \
+ else None
+ builder['is_green'] = not re.search('fail', cell.renderContents())
+
+ status_link_regexp = r"builders/(?P<builder_name>.*)/builds/(?P<build_number>\d+)"
+ link_match = re.match(status_link_regexp, status_link['href'])
+ builder['build_number'] = int(link_match.group("build_number"))
+ else:
+ # We failed to find a link in the first cell, just give up. This
+ # can happen if a builder is just-added, the first cell will just
+ # be "no build"
+ # Other parts of the code depend on is_green being present.
+ builder['is_green'] = False
+ builder['built_revision'] = None
+ builder['build_number'] = None
+
+ def _parse_current_build_cell(self, builder, cell):
+ activity_lines = cell.renderContents().split("<br />")
+ builder["activity"] = activity_lines[0] # normally "building" or "idle"
+ # The middle lines document how long left for any current builds.
+ match = re.match("(?P<pending_builds>\d) pending", activity_lines[-1])
+ builder["pending_builds"] = int(match.group("pending_builds")) if match else 0
+
+ def _parse_builder_status_from_row(self, status_row):
+ status_cells = status_row.findAll('td')
+ builder = {}
+
+ # First cell is the name
+ name_link = status_cells[0].find('a')
+ builder["name"] = name_link.string
+
+ self._parse_last_build_cell(builder, status_cells[1])
+ self._parse_current_build_cell(builder, status_cells[2])
+ return builder
+
+ def _matches_regexps(self, builder_name, name_regexps):
+ for name_regexp in name_regexps:
+ if re.match(name_regexp, builder_name):
+ return True
+ return False
+
+ # FIXME: Should move onto Builder
+ def _is_core_builder(self, builder_name):
+ return self._matches_regexps(builder_name, self.core_builder_names_regexps)
+
+ # FIXME: This method needs to die, but is used by a unit test at the moment.
+ def _builder_statuses_with_names_matching_regexps(self, builder_statuses, name_regexps):
+ return [builder for builder in builder_statuses if self._matches_regexps(builder["name"], name_regexps)]
+
+ def red_core_builders(self):
+ return [builder for builder in self.core_builder_statuses() if not builder["is_green"]]
+
+ def red_core_builders_names(self):
+ return [builder["name"] for builder in self.red_core_builders()]
+
+ def idle_red_core_builders(self):
+ return [builder for builder in self.red_core_builders() if builder["activity"] == "idle"]
+
+ def core_builders_are_green(self):
+ return not self.red_core_builders()
+
+ # FIXME: These _fetch methods should move to a networking class.
+ def _fetch_xmlrpc_build_dictionary(self, builder, build_number):
+ # The buildbot XMLRPC API is super-limited.
+ # For one, you cannot fetch info on builds which are incomplete.
+ proxy = xmlrpclib.ServerProxy("http://%s/xmlrpc" % self.buildbot_host, allow_none=True)
+ try:
+ return proxy.getBuild(builder.name(), int(build_number))
+ except xmlrpclib.Fault, err:
+ build_url = Build.build_url(builder, build_number)
+ _log.error("Error fetching data for %s build %s (%s): %s" % (builder.name(), build_number, build_url, err))
+ return None
+
+ def _fetch_one_box_per_builder(self):
+ build_status_url = "http://%s/one_box_per_builder" % self.buildbot_host
+ return urllib2.urlopen(build_status_url)
+
+ def _parse_twisted_file_row(self, file_row):
+ string_or_empty = lambda string: str(string) if string else ""
+ file_cells = file_row.findAll('td')
+ return {
+ "filename" : string_or_empty(file_cells[0].find("a").string),
+ "size" : string_or_empty(file_cells[1].string),
+ "type" : string_or_empty(file_cells[2].string),
+ "encoding" : string_or_empty(file_cells[3].string),
+ }
+
+ def _parse_twisted_directory_listing(self, page):
+ soup = BeautifulSoup(page)
+ # HACK: Match only table rows with a class to ignore twisted header/footer rows.
+ file_rows = soup.find('table').findAll('tr', { "class" : True })
+ return [self._parse_twisted_file_row(file_row) for file_row in file_rows]
+
+ # FIXME: There should be a better way to get this information directly from twisted.
+ def _fetch_twisted_directory_listing(self, url):
+ return self._parse_twisted_directory_listing(urllib2.urlopen(url))
+
+ def builders(self):
+ return [self.builder_with_name(status["name"]) for status in self.builder_statuses()]
+
+ # This method pulls from /one_box_per_builder as an efficient way to get information about
+ def builder_statuses(self):
+ soup = BeautifulSoup(self._fetch_one_box_per_builder())
+ return [self._parse_builder_status_from_row(status_row) for status_row in soup.find('table').findAll('tr')]
+
+ def core_builder_statuses(self):
+ return [builder for builder in self.builder_statuses() if self._is_core_builder(builder["name"])]
+
+ def builder_with_name(self, name):
+ builder = self._builder_by_name.get(name)
+ if not builder:
+ builder = Builder(name, self)
+ self._builder_by_name[name] = builder
+ return builder
+
+ def revisions_causing_failures(self, only_core_builders=True):
+ builder_statuses = self.core_builder_statuses() if only_core_builders else self.builder_statuses()
+ revision_to_failing_bots = {}
+ for builder_status in builder_statuses:
+ if builder_status["is_green"]:
+ continue
+ builder = self.builder_with_name(builder_status["name"])
+ revisions = builder.blameworthy_revisions(builder_status["build_number"])
+ for revision in revisions:
+ failing_bots = revision_to_failing_bots.get(revision, [])
+ failing_bots.append(builder)
+ revision_to_failing_bots[revision] = failing_bots
+ return revision_to_failing_bots
+
+ # This makes fewer requests than calling Builder.latest_build would. It grabs all builder
+ # statuses in one request using self.builder_statuses (fetching /one_box_per_builder instead of builder pages).
+ def _latest_builds_from_builders(self, only_core_builders=True):
+ builder_statuses = self.core_builder_statuses() if only_core_builders else self.builder_statuses()
+ return [self.builder_with_name(status["name"]).build(status["build_number"]) for status in builder_statuses]
+
+ def _build_at_or_before_revision(self, build, revision):
+ while build:
+ if build.revision() <= revision:
+ return build
+ build = build.previous_build()
+
+ def last_green_revision(self, only_core_builders=True):
+ builds = self._latest_builds_from_builders(only_core_builders)
+ target_revision = builds[0].revision()
+ # An alternate way to do this would be to start at one revision and walk backwards
+ # checking builder.build_for_revision, however build_for_revision is very slow on first load.
+ while True:
+ # Make builds agree on revision
+ builds = [self._build_at_or_before_revision(build, target_revision) for build in builds]
+ if None in builds: # One of the builds failed to load from the server.
+ return None
+ min_revision = min(map(lambda build: build.revision(), builds))
+ if min_revision != target_revision:
+ target_revision = min_revision
+ continue # Builds don't all agree on revision, keep searching
+ # Check to make sure they're all green
+ all_are_green = reduce(operator.and_, map(lambda build: build.is_green(), builds))
+ if not all_are_green:
+ target_revision -= 1
+ continue
+ return min_revision
diff --git a/WebKitTools/Scripts/webkitpy/common/net/buildbot_unittest.py b/WebKitTools/Scripts/webkitpy/common/net/buildbot_unittest.py
new file mode 100644
index 0000000..f765f6e
--- /dev/null
+++ b/WebKitTools/Scripts/webkitpy/common/net/buildbot_unittest.py
@@ -0,0 +1,433 @@
+# Copyright (C) 2009 Google Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# * Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following disclaimer
+# in the documentation and/or other materials provided with the
+# distribution.
+# * Neither the name of Google Inc. nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import unittest
+
+from webkitpy.common.net.buildbot import BuildBot, Builder, Build, LayoutTestResults
+
+from webkitpy.thirdparty.BeautifulSoup import BeautifulSoup
+
+
+class BuilderTest(unittest.TestCase):
+ def _install_fetch_build(self, failure):
+ def _mock_fetch_build(build_number):
+ build = Build(
+ builder=self.builder,
+ build_number=build_number,
+ revision=build_number + 1000,
+ is_green=build_number < 4
+ )
+ build._layout_test_results = LayoutTestResults(
+ "http://buildbot.example.com/foo", {
+ LayoutTestResults.fail_key: failure(build_number),
+ })
+ return build
+ self.builder._fetch_build = _mock_fetch_build
+
+ def setUp(self):
+ self.buildbot = BuildBot()
+ self.builder = Builder("Test Builder", self.buildbot)
+ self._install_fetch_build(lambda build_number: ["test1", "test2"])
+
+ def test_find_failure_transition(self):
+ (green_build, red_build) = self.builder.find_failure_transition(self.builder.build(10))
+ self.assertEqual(green_build.revision(), 1003)
+ self.assertEqual(red_build.revision(), 1004)
+
+ (green_build, red_build) = self.builder.find_failure_transition(self.builder.build(10), look_back_limit=2)
+ self.assertEqual(green_build, None)
+ self.assertEqual(red_build.revision(), 1008)
+
+ def test_none_build(self):
+ self.builder._fetch_build = lambda build_number: None
+ (green_build, red_build) = self.builder.find_failure_transition(self.builder.build(10))
+ self.assertEqual(green_build, None)
+ self.assertEqual(red_build, None)
+
+ def test_flaky_tests(self):
+ self._install_fetch_build(lambda build_number: ["test1"] if build_number % 2 else ["test2"])
+ (green_build, red_build) = self.builder.find_failure_transition(self.builder.build(10))
+ self.assertEqual(green_build.revision(), 1009)
+ self.assertEqual(red_build.revision(), 1010)
+
+ def test_failure_and_flaky(self):
+ self._install_fetch_build(lambda build_number: ["test1", "test2"] if build_number % 2 else ["test2"])
+ (green_build, red_build) = self.builder.find_failure_transition(self.builder.build(10))
+ self.assertEqual(green_build.revision(), 1003)
+ self.assertEqual(red_build.revision(), 1004)
+
+ def test_no_results(self):
+ self._install_fetch_build(lambda build_number: ["test1", "test2"] if build_number % 2 else ["test2"])
+ (green_build, red_build) = self.builder.find_failure_transition(self.builder.build(10))
+ self.assertEqual(green_build.revision(), 1003)
+ self.assertEqual(red_build.revision(), 1004)
+
+ def test_failure_after_flaky(self):
+ self._install_fetch_build(lambda build_number: ["test1", "test2"] if build_number > 6 else ["test3"])
+ (green_build, red_build) = self.builder.find_failure_transition(self.builder.build(10))
+ self.assertEqual(green_build.revision(), 1006)
+ self.assertEqual(red_build.revision(), 1007)
+
+ def test_blameworthy_revisions(self):
+ self.assertEqual(self.builder.blameworthy_revisions(10), [1004])
+ self.assertEqual(self.builder.blameworthy_revisions(10, look_back_limit=2), [])
+ # Flakey test avoidance requires at least 2 red builds:
+ self.assertEqual(self.builder.blameworthy_revisions(4), [])
+ self.assertEqual(self.builder.blameworthy_revisions(4, avoid_flakey_tests=False), [1004])
+ # Green builder:
+ self.assertEqual(self.builder.blameworthy_revisions(3), [])
+
+ def test_build_caching(self):
+ self.assertEqual(self.builder.build(10), self.builder.build(10))
+
+ def test_build_and_revision_for_filename(self):
+ expectations = {
+ "r47483 (1)/" : (47483, 1),
+ "r47483 (1).zip" : (47483, 1),
+ }
+ for filename, revision_and_build in expectations.items():
+ self.assertEqual(self.builder._revision_and_build_for_filename(filename), revision_and_build)
+
+
+class LayoutTestResultsTest(unittest.TestCase):
+ _example_results_html = """
+<html>
+<head>
+<title>Layout Test Results</title>
+</head>
+<body>
+<p>Tests that had stderr output:</p>
+<table>
+<tr>
+<td><a href="/var/lib/buildbot/build/gtk-linux-64-release/build/LayoutTests/accessibility/aria-activedescendant-crash.html">accessibility/aria-activedescendant-crash.html</a></td>
+<td><a href="accessibility/aria-activedescendant-crash-stderr.txt">stderr</a></td>
+</tr>
+<td><a href="/var/lib/buildbot/build/gtk-linux-64-release/build/LayoutTests/http/tests/security/canvas-remote-read-svg-image.html">http/tests/security/canvas-remote-read-svg-image.html</a></td>
+<td><a href="http/tests/security/canvas-remote-read-svg-image-stderr.txt">stderr</a></td>
+</tr>
+</table><p>Tests that had no expected results (probably new):</p>
+<table>
+<tr>
+<td><a href="/var/lib/buildbot/build/gtk-linux-64-release/build/LayoutTests/fast/repaint/no-caret-repaint-in-non-content-editable-element.html">fast/repaint/no-caret-repaint-in-non-content-editable-element.html</a></td>
+<td><a href="fast/repaint/no-caret-repaint-in-non-content-editable-element-actual.txt">result</a></td>
+</tr>
+</table></body>
+</html>
+"""
+
+ _expected_layout_test_results = {
+ 'Tests that had stderr output:' : [
+ 'accessibility/aria-activedescendant-crash.html'
+ ],
+ 'Tests that had no expected results (probably new):' : [
+ 'fast/repaint/no-caret-repaint-in-non-content-editable-element.html'
+ ]
+ }
+ def test_parse_layout_test_results(self):
+ results = LayoutTestResults._parse_results_html(self._example_results_html)
+ self.assertEqual(self._expected_layout_test_results, results)
+
+
+class BuildBotTest(unittest.TestCase):
+
+ _example_one_box_status = '''
+ <table>
+ <tr>
+ <td class="box"><a href="builders/Windows%20Debug%20%28Tests%29">Windows Debug (Tests)</a></td>
+ <td align="center" class="LastBuild box success"><a href="builders/Windows%20Debug%20%28Tests%29/builds/3693">47380</a><br />build<br />successful</td>
+ <td align="center" class="Activity building">building<br />ETA in<br />~ 14 mins<br />at 13:40</td>
+ <tr>
+ <td class="box"><a href="builders/SnowLeopard%20Intel%20Release">SnowLeopard Intel Release</a></td>
+ <td class="LastBuild box" >no build</td>
+ <td align="center" class="Activity building">building<br />< 1 min</td>
+ <tr>
+ <td class="box"><a href="builders/Qt%20Linux%20Release">Qt Linux Release</a></td>
+ <td align="center" class="LastBuild box failure"><a href="builders/Qt%20Linux%20Release/builds/654">47383</a><br />failed<br />compile-webkit</td>
+ <td align="center" class="Activity idle">idle<br />3 pending</td>
+ </table>
+'''
+ _expected_example_one_box_parsings = [
+ {
+ 'is_green': True,
+ 'build_number' : 3693,
+ 'name': u'Windows Debug (Tests)',
+ 'built_revision': 47380,
+ 'activity': 'building',
+ 'pending_builds': 0,
+ },
+ {
+ 'is_green': False,
+ 'build_number' : None,
+ 'name': u'SnowLeopard Intel Release',
+ 'built_revision': None,
+ 'activity': 'building',
+ 'pending_builds': 0,
+ },
+ {
+ 'is_green': False,
+ 'build_number' : 654,
+ 'name': u'Qt Linux Release',
+ 'built_revision': 47383,
+ 'activity': 'idle',
+ 'pending_builds': 3,
+ },
+ ]
+
+ def test_status_parsing(self):
+ buildbot = BuildBot()
+
+ soup = BeautifulSoup(self._example_one_box_status)
+ status_table = soup.find("table")
+ input_rows = status_table.findAll('tr')
+
+ for x in range(len(input_rows)):
+ status_row = input_rows[x]
+ expected_parsing = self._expected_example_one_box_parsings[x]
+
+ builder = buildbot._parse_builder_status_from_row(status_row)
+
+ # Make sure we aren't parsing more or less than we expect
+ self.assertEquals(builder.keys(), expected_parsing.keys())
+
+ for key, expected_value in expected_parsing.items():
+ self.assertEquals(builder[key], expected_value, ("Builder %d parse failure for key: %s: Actual='%s' Expected='%s'" % (x, key, builder[key], expected_value)))
+
+ def test_core_builder_methods(self):
+ buildbot = BuildBot()
+
+ # Override builder_statuses function to not touch the network.
+ def example_builder_statuses(): # We could use instancemethod() to bind 'self' but we don't need to.
+ return BuildBotTest._expected_example_one_box_parsings
+ buildbot.builder_statuses = example_builder_statuses
+
+ buildbot.core_builder_names_regexps = [ 'Leopard', "Windows.*Build" ]
+ self.assertEquals(buildbot.red_core_builders_names(), [])
+ self.assertTrue(buildbot.core_builders_are_green())
+
+ buildbot.core_builder_names_regexps = [ 'SnowLeopard', 'Qt' ]
+ self.assertEquals(buildbot.red_core_builders_names(), [ u'SnowLeopard Intel Release', u'Qt Linux Release' ])
+ self.assertFalse(buildbot.core_builders_are_green())
+
+ def test_builder_name_regexps(self):
+ buildbot = BuildBot()
+
+ # For complete testing, this list should match the list of builders at build.webkit.org:
+ example_builders = [
+ {'name': u'Tiger Intel Release', },
+ {'name': u'Leopard Intel Release (Build)', },
+ {'name': u'Leopard Intel Release (Tests)', },
+ {'name': u'Leopard Intel Debug (Build)', },
+ {'name': u'Leopard Intel Debug (Tests)', },
+ {'name': u'SnowLeopard Intel Release (Build)', },
+ {'name': u'SnowLeopard Intel Release (Tests)', },
+ {'name': u'SnowLeopard Intel Leaks', },
+ {'name': u'Windows Release (Build)', },
+ {'name': u'Windows Release (Tests)', },
+ {'name': u'Windows Debug (Build)', },
+ {'name': u'Windows Debug (Tests)', },
+ {'name': u'GTK Linux 32-bit Release', },
+ {'name': u'GTK Linux 32-bit Debug', },
+ {'name': u'GTK Linux 64-bit Debug', },
+ {'name': u'GTK Linux 64-bit Release', },
+ {'name': u'Qt Linux Release', },
+ {'name': u'Qt Linux Release minimal', },
+ {'name': u'Qt Linux ARMv5 Release', },
+ {'name': u'Qt Linux ARMv7 Release', },
+ {'name': u'Qt Windows 32-bit Release', },
+ {'name': u'Qt Windows 32-bit Debug', },
+ {'name': u'Chromium Linux Release', },
+ {'name': u'Chromium Mac Release', },
+ {'name': u'Chromium Win Release', },
+ {'name': u'New run-webkit-tests', },
+ ]
+ name_regexps = [
+ "SnowLeopard.*Build",
+ "SnowLeopard.*Test",
+ "Leopard",
+ "Tiger",
+ "Windows.*Build",
+ "Windows.*Debug.*Test",
+ "GTK",
+ "Qt",
+ "Chromium",
+ ]
+ expected_builders = [
+ {'name': u'Tiger Intel Release', },
+ {'name': u'Leopard Intel Release (Build)', },
+ {'name': u'Leopard Intel Release (Tests)', },
+ {'name': u'Leopard Intel Debug (Build)', },
+ {'name': u'Leopard Intel Debug (Tests)', },
+ {'name': u'SnowLeopard Intel Release (Build)', },
+ {'name': u'SnowLeopard Intel Release (Tests)', },
+ {'name': u'Windows Release (Build)', },
+ {'name': u'Windows Debug (Build)', },
+ {'name': u'Windows Debug (Tests)', },
+ {'name': u'GTK Linux 32-bit Release', },
+ {'name': u'GTK Linux 32-bit Debug', },
+ {'name': u'GTK Linux 64-bit Debug', },
+ {'name': u'GTK Linux 64-bit Release', },
+ {'name': u'Qt Linux Release', },
+ {'name': u'Qt Linux Release minimal', },
+ {'name': u'Qt Linux ARMv5 Release', },
+ {'name': u'Qt Linux ARMv7 Release', },
+ {'name': u'Qt Windows 32-bit Release', },
+ {'name': u'Qt Windows 32-bit Debug', },
+ {'name': u'Chromium Linux Release', },
+ {'name': u'Chromium Mac Release', },
+ {'name': u'Chromium Win Release', },
+ ]
+
+ # This test should probably be updated if the default regexp list changes
+ self.assertEquals(buildbot.core_builder_names_regexps, name_regexps)
+
+ builders = buildbot._builder_statuses_with_names_matching_regexps(example_builders, name_regexps)
+ self.assertEquals(builders, expected_builders)
+
+ def test_builder_with_name(self):
+ buildbot = BuildBot()
+
+ builder = buildbot.builder_with_name("Test Builder")
+ self.assertEqual(builder.name(), "Test Builder")
+ self.assertEqual(builder.url(), "http://build.webkit.org/builders/Test%20Builder")
+ self.assertEqual(builder.url_encoded_name(), "Test%20Builder")
+ self.assertEqual(builder.results_url(), "http://build.webkit.org/results/Test%20Builder")
+
+ # Override _fetch_xmlrpc_build_dictionary function to not touch the network.
+ def mock_fetch_xmlrpc_build_dictionary(self, build_number):
+ build_dictionary = {
+ "revision" : 2 * build_number,
+ "number" : int(build_number),
+ "results" : build_number % 2, # 0 means pass
+ }
+ return build_dictionary
+ buildbot._fetch_xmlrpc_build_dictionary = mock_fetch_xmlrpc_build_dictionary
+
+ build = builder.build(10)
+ self.assertEqual(build.builder(), builder)
+ self.assertEqual(build.url(), "http://build.webkit.org/builders/Test%20Builder/builds/10")
+ self.assertEqual(build.results_url(), "http://build.webkit.org/results/Test%20Builder/r20%20%2810%29")
+ self.assertEqual(build.revision(), 20)
+ self.assertEqual(build.is_green(), True)
+
+ build = build.previous_build()
+ self.assertEqual(build.builder(), builder)
+ self.assertEqual(build.url(), "http://build.webkit.org/builders/Test%20Builder/builds/9")
+ self.assertEqual(build.results_url(), "http://build.webkit.org/results/Test%20Builder/r18%20%289%29")
+ self.assertEqual(build.revision(), 18)
+ self.assertEqual(build.is_green(), False)
+
+ self.assertEqual(builder.build(None), None)
+
+ _example_directory_listing = '''
+<h1>Directory listing for /results/SnowLeopard Intel Leaks/</h1>
+
+<table>
+ <thead>
+ <tr>
+ <th>Filename</th>
+ <th>Size</th>
+ <th>Content type</th>
+ <th>Content encoding</th>
+ </tr>
+ </thead>
+ <tbody>
+<tr class="odd">
+ <td><a href="r47483%20%281%29/">r47483 (1)/</a></td>
+ <td></td>
+ <td>[Directory]</td>
+ <td></td>
+</tr>
+<tr class="odd">
+ <td><a href="r47484%20%282%29.zip">r47484 (2).zip</a></td>
+ <td>89K</td>
+ <td>[application/zip]</td>
+ <td></td>
+</tr>
+'''
+ _expected_files = [
+ {
+ "filename" : "r47483 (1)/",
+ "size" : "",
+ "type" : "[Directory]",
+ "encoding" : "",
+ },
+ {
+ "filename" : "r47484 (2).zip",
+ "size" : "89K",
+ "type" : "[application/zip]",
+ "encoding" : "",
+ },
+ ]
+
+ def test_parse_build_to_revision_map(self):
+ buildbot = BuildBot()
+ files = buildbot._parse_twisted_directory_listing(self._example_directory_listing)
+ self.assertEqual(self._expected_files, files)
+
+ # Revision, is_green
+ # Ordered from newest (highest number) to oldest.
+ fake_builder1 = [
+ [2, False],
+ [1, True],
+ ]
+ fake_builder2 = [
+ [2, False],
+ [1, True],
+ ]
+ fake_builders = [
+ fake_builder1,
+ fake_builder2,
+ ]
+ def _build_from_fake(self, fake_builder, index):
+ if index >= len(fake_builder):
+ return None
+ fake_build = fake_builder[index]
+ build = Build(
+ builder=fake_builder,
+ build_number=index,
+ revision=fake_build[0],
+ is_green=fake_build[1],
+ )
+ def mock_previous_build():
+ return self._build_from_fake(fake_builder, index + 1)
+ build.previous_build = mock_previous_build
+ return build
+
+ def _fake_builds_at_index(self, index):
+ return [self._build_from_fake(builder, index) for builder in self.fake_builders]
+
+ def test_last_green_revision(self):
+ buildbot = BuildBot()
+ def mock_builds_from_builders(only_core_builders):
+ return self._fake_builds_at_index(0)
+ buildbot._latest_builds_from_builders = mock_builds_from_builders
+ self.assertEqual(buildbot.last_green_revision(), 1)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/WebKitTools/Scripts/webkitpy/common/net/credentials.py b/WebKitTools/Scripts/webkitpy/common/net/credentials.py
new file mode 100644
index 0000000..1d5f83d
--- /dev/null
+++ b/WebKitTools/Scripts/webkitpy/common/net/credentials.py
@@ -0,0 +1,126 @@
+# Copyright (c) 2009 Google Inc. All rights reserved.
+# Copyright (c) 2009 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# * Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following disclaimer
+# in the documentation and/or other materials provided with the
+# distribution.
+# * Neither the name of Google Inc. nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+#
+# Python module for reading stored web credentials from the OS.
+
+import getpass
+import os
+import platform
+import re
+
+from webkitpy.common.checkout.scm import Git
+from webkitpy.common.system.executive import Executive, ScriptError
+from webkitpy.common.system.user import User
+from webkitpy.common.system.deprecated_logging import log
+
+
+class Credentials(object):
+
+ def __init__(self, host, git_prefix=None, executive=None, cwd=os.getcwd()):
+ self.host = host
+ self.git_prefix = "%s." % git_prefix if git_prefix else ""
+ self.executive = executive or Executive()
+ self.cwd = cwd
+
+ def _credentials_from_git(self):
+ return [Git.read_git_config(self.git_prefix + "username"),
+ Git.read_git_config(self.git_prefix + "password")]
+
+ def _keychain_value_with_label(self, label, source_text):
+ match = re.search("%s\"(?P<value>.+)\"" % label,
+ source_text,
+ re.MULTILINE)
+ if match:
+ return match.group('value')
+
+ def _is_mac_os_x(self):
+ return platform.mac_ver()[0]
+
+ def _parse_security_tool_output(self, security_output):
+ username = self._keychain_value_with_label("^\s*\"acct\"<blob>=",
+ security_output)
+ password = self._keychain_value_with_label("^password: ",
+ security_output)
+ return [username, password]
+
+ def _run_security_tool(self, username=None):
+ security_command = [
+ "/usr/bin/security",
+ "find-internet-password",
+ "-g",
+ "-s",
+ self.host,
+ ]
+ if username:
+ security_command += ["-a", username]
+
+ log("Reading Keychain for %s account and password. "
+ "Click \"Allow\" to continue..." % self.host)
+ try:
+ return self.executive.run_command(security_command)
+ except ScriptError:
+ # Failed to either find a keychain entry or somekind of OS-related
+ # error occured (for instance, couldn't find the /usr/sbin/security
+ # command).
+ log("Could not find a keychain entry for %s." % self.host)
+ return None
+
+ def _credentials_from_keychain(self, username=None):
+ if not self._is_mac_os_x():
+ return [username, None]
+
+ security_output = self._run_security_tool(username)
+ if security_output:
+ return self._parse_security_tool_output(security_output)
+ else:
+ return [None, None]
+
+ def read_credentials(self):
+ username = None
+ password = None
+
+ try:
+ if Git.in_working_directory(self.cwd):
+ (username, password) = self._credentials_from_git()
+ except OSError, e:
+ # Catch and ignore OSError exceptions such as "no such file
+ # or directory" (OSError errno 2), which imply that the Git
+ # command cannot be found/is not installed.
+ pass
+
+ if not username or not password:
+ (username, password) = self._credentials_from_keychain(username)
+
+ if not username:
+ username = User.prompt("%s login: " % self.host)
+ if not password:
+ password = getpass.getpass("%s password for %s: " % (self.host,
+ username))
+
+ return [username, password]
diff --git a/WebKitTools/Scripts/webkitpy/common/net/credentials_unittest.py b/WebKitTools/Scripts/webkitpy/common/net/credentials_unittest.py
new file mode 100644
index 0000000..9a42bdd
--- /dev/null
+++ b/WebKitTools/Scripts/webkitpy/common/net/credentials_unittest.py
@@ -0,0 +1,117 @@
+# Copyright (C) 2009 Google Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# * Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following disclaimer
+# in the documentation and/or other materials provided with the
+# distribution.
+# * Neither the name of Google Inc. nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import os
+import tempfile
+import unittest
+from webkitpy.common.net.credentials import Credentials
+from webkitpy.common.system.executive import Executive
+from webkitpy.common.system.outputcapture import OutputCapture
+from webkitpy.thirdparty.mock import Mock
+
+class CredentialsTest(unittest.TestCase):
+ example_security_output = """keychain: "/Users/test/Library/Keychains/login.keychain"
+class: "inet"
+attributes:
+ 0x00000007 <blob>="bugs.webkit.org (test@webkit.org)"
+ 0x00000008 <blob>=<NULL>
+ "acct"<blob>="test@webkit.org"
+ "atyp"<blob>="form"
+ "cdat"<timedate>=0x32303039303832353233353231365A00 "20090825235216Z\000"
+ "crtr"<uint32>=<NULL>
+ "cusi"<sint32>=<NULL>
+ "desc"<blob>="Web form password"
+ "icmt"<blob>="default"
+ "invi"<sint32>=<NULL>
+ "mdat"<timedate>=0x32303039303930393137323635315A00 "20090909172651Z\000"
+ "nega"<sint32>=<NULL>
+ "path"<blob>=<NULL>
+ "port"<uint32>=0x00000000
+ "prot"<blob>=<NULL>
+ "ptcl"<uint32>="htps"
+ "scrp"<sint32>=<NULL>
+ "sdmn"<blob>=<NULL>
+ "srvr"<blob>="bugs.webkit.org"
+ "type"<uint32>=<NULL>
+password: "SECRETSAUCE"
+"""
+
+ def test_keychain_lookup_on_non_mac(self):
+ class FakeCredentials(Credentials):
+ def _is_mac_os_x(self):
+ return False
+ credentials = FakeCredentials("bugs.webkit.org")
+ self.assertEqual(credentials._is_mac_os_x(), False)
+ self.assertEqual(credentials._credentials_from_keychain("foo"), ["foo", None])
+
+ def test_security_output_parse(self):
+ credentials = Credentials("bugs.webkit.org")
+ self.assertEqual(credentials._parse_security_tool_output(self.example_security_output), ["test@webkit.org", "SECRETSAUCE"])
+
+ def test_security_output_parse_entry_not_found(self):
+ credentials = Credentials("foo.example.com")
+ if not credentials._is_mac_os_x():
+ return # This test does not run on a non-Mac.
+
+ # Note, we ignore the captured output because it is already covered
+ # by the test case CredentialsTest._assert_security_call (below).
+ outputCapture = OutputCapture()
+ outputCapture.capture_output()
+ self.assertEqual(credentials._run_security_tool(), None)
+ outputCapture.restore_output()
+
+ def _assert_security_call(self, username=None):
+ executive_mock = Mock()
+ credentials = Credentials("example.com", executive=executive_mock)
+
+ expected_stderr = "Reading Keychain for example.com account and password. Click \"Allow\" to continue...\n"
+ OutputCapture().assert_outputs(self, credentials._run_security_tool, [username], expected_stderr=expected_stderr)
+
+ security_args = ["/usr/bin/security", "find-internet-password", "-g", "-s", "example.com"]
+ if username:
+ security_args += ["-a", username]
+ executive_mock.run_command.assert_called_with(security_args)
+
+ def test_security_calls(self):
+ self._assert_security_call()
+ self._assert_security_call(username="foo")
+
+ def test_read_credentials_without_git_repo(self):
+ class FakeCredentials(Credentials):
+ def _is_mac_os_x(self):
+ return True
+ def _credentials_from_keychain(self, username):
+ return ["test@webkit.org", "SECRETSAUCE"]
+
+ temp_dir_path = tempfile.mkdtemp(suffix="not_a_git_repo")
+ credentials = FakeCredentials("bugs.webkit.org", cwd=temp_dir_path)
+ self.assertEqual(credentials.read_credentials(), ["test@webkit.org", "SECRETSAUCE"])
+ os.rmdir(temp_dir_path)
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/WebKitTools/Scripts/webkitpy/common/net/irc/__init__.py b/WebKitTools/Scripts/webkitpy/common/net/irc/__init__.py
new file mode 100644
index 0000000..ef65bee
--- /dev/null
+++ b/WebKitTools/Scripts/webkitpy/common/net/irc/__init__.py
@@ -0,0 +1 @@
+# Required for Python to search this directory for module files
diff --git a/WebKitTools/Scripts/webkitpy/common/net/irc/ircbot.py b/WebKitTools/Scripts/webkitpy/common/net/irc/ircbot.py
new file mode 100644
index 0000000..f742867
--- /dev/null
+++ b/WebKitTools/Scripts/webkitpy/common/net/irc/ircbot.py
@@ -0,0 +1,91 @@
+# Copyright (c) 2010 Google Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# * Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following disclaimer
+# in the documentation and/or other materials provided with the
+# distribution.
+# * Neither the name of Google Inc. nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import webkitpy.common.config.irc as config_irc
+
+from webkitpy.common.thread.messagepump import MessagePump, MessagePumpDelegate
+from webkitpy.thirdparty.autoinstalled.irc import ircbot
+from webkitpy.thirdparty.autoinstalled.irc import irclib
+
+
+class IRCBotDelegate(object):
+ def irc_message_received(self, nick, message):
+ raise NotImplementedError, "subclasses must implement"
+
+ def irc_nickname(self):
+ raise NotImplementedError, "subclasses must implement"
+
+ def irc_password(self):
+ raise NotImplementedError, "subclasses must implement"
+
+
+class IRCBot(ircbot.SingleServerIRCBot, MessagePumpDelegate):
+ # FIXME: We should get this information from a config file.
+ def __init__(self,
+ message_queue,
+ delegate):
+ self._message_queue = message_queue
+ self._delegate = delegate
+ ircbot.SingleServerIRCBot.__init__(
+ self,
+ [(
+ config_irc.server,
+ config_irc.port,
+ self._delegate.irc_password()
+ )],
+ self._delegate.irc_nickname(),
+ self._delegate.irc_nickname())
+ self._channel = config_irc.channel
+
+ # ircbot.SingleServerIRCBot methods
+
+ def on_nicknameinuse(self, connection, event):
+ connection.nick(connection.get_nickname() + "_")
+
+ def on_welcome(self, connection, event):
+ connection.join(self._channel)
+ self._message_pump = MessagePump(self, self._message_queue)
+
+ def on_pubmsg(self, connection, event):
+ nick = irclib.nm_to_n(event.source())
+ request = event.arguments()[0].split(":", 1)
+ if len(request) > 1 and irclib.irc_lower(request[0]) == irclib.irc_lower(self.connection.get_nickname()):
+ response = self._delegate.irc_message_received(nick, request[1])
+ if response:
+ connection.privmsg(self._channel, response)
+
+ # MessagePumpDelegate methods
+
+ def schedule(self, interval, callback):
+ self.connection.execute_delayed(interval, callback)
+
+ def message_available(self, message):
+ self.connection.privmsg(self._channel, message)
+
+ def final_message_delivered(self):
+ self.die()
diff --git a/WebKitTools/Scripts/webkitpy/common/net/irc/ircproxy.py b/WebKitTools/Scripts/webkitpy/common/net/irc/ircproxy.py
new file mode 100644
index 0000000..13348b4
--- /dev/null
+++ b/WebKitTools/Scripts/webkitpy/common/net/irc/ircproxy.py
@@ -0,0 +1,62 @@
+# Copyright (c) 2010 Google Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# * Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following disclaimer
+# in the documentation and/or other materials provided with the
+# distribution.
+# * Neither the name of Google Inc. nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import threading
+
+from webkitpy.common.net.irc.ircbot import IRCBot
+from webkitpy.common.thread.threadedmessagequeue import ThreadedMessageQueue
+from webkitpy.common.system.deprecated_logging import log
+
+
+class _IRCThread(threading.Thread):
+ def __init__(self, message_queue, irc_delegate, irc_bot):
+ threading.Thread.__init__(self)
+ self.setDaemon(True)
+ self._message_queue = message_queue
+ self._irc_delegate = irc_delegate
+ self._irc_bot = irc_bot
+
+ def run(self):
+ bot = self._irc_bot(self._message_queue, self._irc_delegate)
+ bot.start()
+
+
+class IRCProxy(object):
+ def __init__(self, irc_delegate, irc_bot=IRCBot):
+ log("Connecting to IRC")
+ self._message_queue = ThreadedMessageQueue()
+ self._child_thread = _IRCThread(self._message_queue, irc_delegate, irc_bot)
+ self._child_thread.start()
+
+ def post(self, message):
+ self._message_queue.post(message)
+
+ def disconnect(self):
+ log("Disconnecting from IRC...")
+ self._message_queue.stop()
+ self._child_thread.join()
diff --git a/WebKitTools/Scripts/webkitpy/common/net/irc/ircproxy_unittest.py b/WebKitTools/Scripts/webkitpy/common/net/irc/ircproxy_unittest.py
new file mode 100644
index 0000000..b44ce40
--- /dev/null
+++ b/WebKitTools/Scripts/webkitpy/common/net/irc/ircproxy_unittest.py
@@ -0,0 +1,43 @@
+# Copyright (c) 2010 Google Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# * Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following disclaimer
+# in the documentation and/or other materials provided with the
+# distribution.
+# * Neither the name of Google Inc. nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import unittest
+
+from webkitpy.common.net.irc.ircproxy import IRCProxy
+from webkitpy.common.system.outputcapture import OutputCapture
+from webkitpy.thirdparty.mock import Mock
+
+class IRCProxyTest(unittest.TestCase):
+ def test_trivial(self):
+ def fun():
+ proxy = IRCProxy(Mock(), Mock())
+ proxy.post("hello")
+ proxy.disconnect()
+
+ expected_stderr = "Connecting to IRC\nDisconnecting from IRC...\n"
+ OutputCapture().assert_outputs(self, fun, expected_stderr=expected_stderr)
diff --git a/WebKitTools/Scripts/webkitpy/common/net/networktransaction.py b/WebKitTools/Scripts/webkitpy/common/net/networktransaction.py
new file mode 100644
index 0000000..c82fc6f
--- /dev/null
+++ b/WebKitTools/Scripts/webkitpy/common/net/networktransaction.py
@@ -0,0 +1,68 @@
+# Copyright (C) 2010 Google Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# * Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following disclaimer
+# in the documentation and/or other materials provided with the
+# distribution.
+# * Neither the name of Google Inc. nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import logging
+import time
+
+from webkitpy.thirdparty.autoinstalled.mechanize import HTTPError
+from webkitpy.common.system.deprecated_logging import log
+
+
+_log = logging.getLogger(__name__)
+
+
+class NetworkTimeout(Exception):
+ pass
+
+
+class NetworkTransaction(object):
+ def __init__(self, initial_backoff_seconds=10, grown_factor=1.5, timeout_seconds=10*60):
+ self._initial_backoff_seconds = initial_backoff_seconds
+ self._grown_factor = grown_factor
+ self._timeout_seconds = timeout_seconds
+
+ def run(self, request):
+ self._total_sleep = 0
+ self._backoff_seconds = self._initial_backoff_seconds
+ while True:
+ try:
+ return request()
+ except HTTPError, e:
+ self._check_for_timeout()
+ _log.warn("Received HTTP status %s from server. Retrying in "
+ "%s seconds..." % (e.code, self._backoff_seconds))
+ self._sleep()
+
+ def _check_for_timeout(self):
+ if self._total_sleep + self._backoff_seconds > self._timeout_seconds:
+ raise NetworkTimeout()
+
+ def _sleep(self):
+ time.sleep(self._backoff_seconds)
+ self._total_sleep += self._backoff_seconds
+ self._backoff_seconds *= self._grown_factor
diff --git a/WebKitTools/Scripts/webkitpy/common/net/networktransaction_unittest.py b/WebKitTools/Scripts/webkitpy/common/net/networktransaction_unittest.py
new file mode 100644
index 0000000..cd0702b
--- /dev/null
+++ b/WebKitTools/Scripts/webkitpy/common/net/networktransaction_unittest.py
@@ -0,0 +1,86 @@
+# Copyright (c) 2010 Google Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# * Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following disclaimer
+# in the documentation and/or other materials provided with the
+# distribution.
+# * Neither the name of Google Inc. nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import unittest
+
+from webkitpy.common.net.networktransaction import NetworkTransaction, NetworkTimeout
+from webkitpy.common.system.logtesting import LoggingTestCase
+from webkitpy.thirdparty.autoinstalled.mechanize import HTTPError
+
+
+class NetworkTransactionTest(LoggingTestCase):
+ exception = Exception("Test exception")
+
+ def test_success(self):
+ transaction = NetworkTransaction()
+ self.assertEqual(transaction.run(lambda: 42), 42)
+
+ def _raise_exception(self):
+ raise self.exception
+
+ def test_exception(self):
+ transaction = NetworkTransaction()
+ did_process_exception = False
+ did_throw_exception = True
+ try:
+ transaction.run(lambda: self._raise_exception())
+ did_throw_exception = False
+ except Exception, e:
+ did_process_exception = True
+ self.assertEqual(e, self.exception)
+ self.assertTrue(did_throw_exception)
+ self.assertTrue(did_process_exception)
+
+ def _raise_http_error(self):
+ self._run_count += 1
+ if self._run_count < 3:
+ raise HTTPError("http://example.com/", 500, "inteneral server error", None, None)
+ return 42
+
+ def test_retry(self):
+ self._run_count = 0
+ transaction = NetworkTransaction(initial_backoff_seconds=0)
+ self.assertEqual(transaction.run(lambda: self._raise_http_error()), 42)
+ self.assertEqual(self._run_count, 3)
+ self.assertLog(['WARNING: Received HTTP status 500 from server. '
+ 'Retrying in 0 seconds...\n',
+ 'WARNING: Received HTTP status 500 from server. '
+ 'Retrying in 0.0 seconds...\n'])
+
+ def test_timeout(self):
+ self._run_count = 0
+ transaction = NetworkTransaction(initial_backoff_seconds=60*60, timeout_seconds=60)
+ did_process_exception = False
+ did_throw_exception = True
+ try:
+ transaction.run(lambda: self._raise_http_error())
+ did_throw_exception = False
+ except NetworkTimeout, e:
+ did_process_exception = True
+ self.assertTrue(did_throw_exception)
+ self.assertTrue(did_process_exception)
diff --git a/WebKitTools/Scripts/webkitpy/common/net/rietveld.py b/WebKitTools/Scripts/webkitpy/common/net/rietveld.py
new file mode 100644
index 0000000..a9e5b1a
--- /dev/null
+++ b/WebKitTools/Scripts/webkitpy/common/net/rietveld.py
@@ -0,0 +1,89 @@
+# Copyright (c) 2010 Google Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# * Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following disclaimer
+# in the documentation and/or other materials provided with the
+# distribution.
+# * Neither the name of Google Inc. nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import os
+import re
+import stat
+
+import webkitpy.common.config as config
+from webkitpy.common.system.deprecated_logging import log
+from webkitpy.common.system.executive import ScriptError
+import webkitpy.thirdparty.autoinstalled.rietveld.upload as upload
+
+
+def parse_codereview_issue(message):
+ if not message:
+ return None
+ match = re.search(config.codereview_server_regex +
+ "(?P<codereview_issue>\d+)",
+ message)
+ if match:
+ return int(match.group('codereview_issue'))
+
+
+class Rietveld(object):
+ def __init__(self, executive, dryrun=False):
+ self.dryrun = dryrun
+ self._executive = executive
+ self._upload_py = upload.__file__
+ # Chop off the last character so we modify permissions on the py file instead of the pyc.
+ if os.path.splitext(self._upload_py)[1] == ".pyc":
+ self._upload_py = self._upload_py[:-1]
+ os.chmod(self._upload_py, os.stat(self._upload_py).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
+
+ def url_for_issue(self, codereview_issue):
+ if not codereview_issue:
+ return None
+ return "%s%s" % (config.codereview_server_url, codereview_issue)
+
+ def post(self, message=None, codereview_issue=None, cc=None):
+ if not message:
+ raise ScriptError("Rietveld requires a message.")
+
+ args = [
+ self._upload_py,
+ "--assume_yes",
+ "--server=%s" % config.codereview_server_host,
+ "--message=%s" % message,
+ ]
+ if codereview_issue:
+ args.append("--issue=%s" % codereview_issue)
+ if cc:
+ args.append("--cc=%s" % cc)
+
+ if self.dryrun:
+ log("Would have run %s" % args)
+ return
+
+ output = self._executive.run_and_throw_if_fail(args)
+ match = re.search("Issue created\. URL: " +
+ config.codereview_server_regex +
+ "(?P<codereview_issue>\d+)",
+ output)
+ if match:
+ return int(match.group('codereview_issue'))
diff --git a/WebKitTools/Scripts/webkitpy/common/net/rietveld_unittest.py b/WebKitTools/Scripts/webkitpy/common/net/rietveld_unittest.py
new file mode 100644
index 0000000..9c5a29e
--- /dev/null
+++ b/WebKitTools/Scripts/webkitpy/common/net/rietveld_unittest.py
@@ -0,0 +1,39 @@
+# Copyright (c) 2010 Google Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# * Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following disclaimer
+# in the documentation and/or other materials provided with the
+# distribution.
+# * Neither the name of Google Inc. nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import unittest
+
+from webkitpy.common.net.rietveld import Rietveld
+from webkitpy.thirdparty.mock import Mock
+
+
+class RietveldTest(unittest.TestCase):
+ def test_url_for_issue(self):
+ rietveld = Rietveld(Mock())
+ self.assertEqual(rietveld.url_for_issue(34223),
+ "https://wkrietveld.appspot.com/34223")
diff --git a/WebKitTools/Scripts/webkitpy/common/net/statusserver.py b/WebKitTools/Scripts/webkitpy/common/net/statusserver.py
new file mode 100644
index 0000000..e8987a9
--- /dev/null
+++ b/WebKitTools/Scripts/webkitpy/common/net/statusserver.py
@@ -0,0 +1,108 @@
+# Copyright (C) 2009 Google Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# * Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following disclaimer
+# in the documentation and/or other materials provided with the
+# distribution.
+# * Neither the name of Google Inc. nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+from webkitpy.common.net.networktransaction import NetworkTransaction
+from webkitpy.common.system.deprecated_logging import log
+from webkitpy.thirdparty.autoinstalled.mechanize import Browser
+from webkitpy.thirdparty.BeautifulSoup import BeautifulSoup
+
+import urllib2
+
+
+class StatusServer:
+ default_host = "webkit-commit-queue.appspot.com"
+
+ def __init__(self, host=default_host):
+ self.set_host(host)
+ self.browser = Browser()
+
+ def set_host(self, host):
+ self.host = host
+ self.url = "http://%s" % self.host
+
+ def results_url_for_status(self, status_id):
+ return "%s/results/%s" % (self.url, status_id)
+
+ def _add_patch(self, patch):
+ if not patch:
+ return
+ if patch.bug_id():
+ self.browser["bug_id"] = str(patch.bug_id())
+ if patch.id():
+ self.browser["patch_id"] = str(patch.id())
+
+ def _add_results_file(self, results_file):
+ if not results_file:
+ return
+ self.browser.add_file(results_file, "text/plain", "results.txt", 'results_file')
+
+ def _post_status_to_server(self, queue_name, status, patch, results_file):
+ if results_file:
+ # We might need to re-wind the file if we've already tried to post it.
+ results_file.seek(0)
+
+ update_status_url = "%s/update-status" % self.url
+ self.browser.open(update_status_url)
+ self.browser.select_form(name="update_status")
+ self.browser["queue_name"] = queue_name
+ self._add_patch(patch)
+ self.browser["status"] = status
+ self._add_results_file(results_file)
+ return self.browser.submit().read() # This is the id of the newly created status object.
+
+ def _post_svn_revision_to_server(self, svn_revision_number, broken_bot):
+ update_svn_revision_url = "%s/update-svn-revision" % self.url
+ self.browser.open(update_svn_revision_url)
+ self.browser.select_form(name="update_svn_revision")
+ self.browser["number"] = str(svn_revision_number)
+ self.browser["broken_bot"] = broken_bot
+ return self.browser.submit().read()
+
+ def update_status(self, queue_name, status, patch=None, results_file=None):
+ log(status)
+ return NetworkTransaction().run(lambda: self._post_status_to_server(queue_name, status, patch, results_file))
+
+ def update_svn_revision(self, svn_revision_number, broken_bot):
+ log("SVN revision: %s broke %s" % (svn_revision_number, broken_bot))
+ return NetworkTransaction().run(lambda: self._post_svn_revision_to_server(svn_revision_number, broken_bot))
+
+ def _fetch_url(self, url):
+ try:
+ return urllib2.urlopen(url).read()
+ except urllib2.HTTPError, e:
+ if e.code == 404:
+ return None
+ raise e
+
+ def patch_status(self, queue_name, patch_id):
+ patch_status_url = "%s/patch-status/%s/%s" % (self.url, queue_name, patch_id)
+ return self._fetch_url(patch_status_url)
+
+ def svn_revision(self, svn_revision_number):
+ svn_revision_url = "%s/svn-revision/%s" % (self.url, svn_revision_number)
+ return self._fetch_url(svn_revision_url)
diff --git a/WebKitTools/Scripts/webkitpy/common/prettypatch.py b/WebKitTools/Scripts/webkitpy/common/prettypatch.py
new file mode 100644
index 0000000..8157f9c
--- /dev/null
+++ b/WebKitTools/Scripts/webkitpy/common/prettypatch.py
@@ -0,0 +1,55 @@
+# Copyright (c) 2010 Google Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# * Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following disclaimer
+# in the documentation and/or other materials provided with the
+# distribution.
+# * Neither the name of Google Inc. nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import os
+import tempfile
+
+
+class PrettyPatch(object):
+ def __init__(self, executive, checkout_root):
+ self._executive = executive
+ self._checkout_root = checkout_root
+
+ def pretty_diff_file(self, diff):
+ pretty_diff = self.pretty_diff(diff)
+ diff_file = tempfile.NamedTemporaryFile(suffix=".html")
+ diff_file.write(pretty_diff)
+ diff_file.flush()
+ return diff_file
+
+ def pretty_diff(self, diff):
+ pretty_patch_path = os.path.join(self._checkout_root,
+ "BugsSite", "PrettyPatch")
+ prettify_path = os.path.join(pretty_patch_path, "prettify.rb")
+ args = [
+ "ruby",
+ "-I",
+ pretty_patch_path,
+ prettify_path,
+ ]
+ return self._executive.run_command(args, input=diff)
diff --git a/WebKitTools/Scripts/webkitpy/common/system/__init__.py b/WebKitTools/Scripts/webkitpy/common/system/__init__.py
new file mode 100644
index 0000000..ef65bee
--- /dev/null
+++ b/WebKitTools/Scripts/webkitpy/common/system/__init__.py
@@ -0,0 +1 @@
+# Required for Python to search this directory for module files
diff --git a/WebKitTools/Scripts/webkitpy/common/system/autoinstall.py b/WebKitTools/Scripts/webkitpy/common/system/autoinstall.py
new file mode 100755
index 0000000..32fd2cf
--- /dev/null
+++ b/WebKitTools/Scripts/webkitpy/common/system/autoinstall.py
@@ -0,0 +1,518 @@
+# Copyright (c) 2009, Daniel Krech All rights reserved.
+# Copyright (C) 2010 Chris Jerdonek (cjerdonek@webkit.org)
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+#
+# * Redistributions in binary form must reproduce the above copyright
+# notice, this list of conditions and the following disclaimer in the
+# documentation and/or other materials provided with the distribution.
+#
+# * Neither the name of the Daniel Krech nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+"""Support for automatically downloading Python packages from an URL."""
+
+import logging
+import new
+import os
+import shutil
+import sys
+import tarfile
+import tempfile
+import urllib
+import urlparse
+import zipfile
+import zipimport
+
+_log = logging.getLogger(__name__)
+
+
+class AutoInstaller(object):
+
+ """Supports automatically installing Python packages from an URL.
+
+ Supports uncompressed files, .tar.gz, and .zip formats.
+
+ Basic usage:
+
+ installer = AutoInstaller()
+
+ installer.install(url="http://pypi.python.org/packages/source/p/pep8/pep8-0.5.0.tar.gz#md5=512a818af9979290cd619cce8e9c2e2b",
+ url_subpath="pep8-0.5.0/pep8.py")
+ installer.install(url="http://pypi.python.org/packages/source/m/mechanize/mechanize-0.1.11.zip",
+ url_subpath="mechanize")
+
+ """
+
+ def __init__(self, append_to_search_path=False, make_package=True,
+ target_dir=None, temp_dir=None):
+ """Create an AutoInstaller instance, and set up the target directory.
+
+ Args:
+ append_to_search_path: A boolean value of whether to append the
+ target directory to the sys.path search path.
+ make_package: A boolean value of whether to make the target
+ directory a package. This adds an __init__.py file
+ to the target directory -- allowing packages and
+ modules within the target directory to be imported
+ explicitly using dotted module names.
+ target_dir: The directory path to which packages should be installed.
+ Defaults to a subdirectory of the folder containing
+ this module called "autoinstalled".
+ temp_dir: The directory path to use for any temporary files
+ generated while downloading, unzipping, and extracting
+ packages to install. Defaults to a standard temporary
+ location generated by the tempfile module. This
+ parameter should normally be used only for development
+ testing.
+
+ """
+ if target_dir is None:
+ this_dir = os.path.dirname(__file__)
+ target_dir = os.path.join(this_dir, "autoinstalled")
+
+ # Ensure that the target directory exists.
+ self._set_up_target_dir(target_dir, append_to_search_path, make_package)
+
+ self._target_dir = target_dir
+ self._temp_dir = temp_dir
+
+ def _log_transfer(self, message, source, target, log_method=None):
+ """Log a debug message that involves a source and target."""
+ if log_method is None:
+ log_method = _log.debug
+
+ log_method("%s" % message)
+ log_method(' From: "%s"' % source)
+ log_method(' To: "%s"' % target)
+
+ def _create_directory(self, path, name=None):
+ """Create a directory."""
+ log = _log.debug
+
+ name = name + " " if name is not None else ""
+ log('Creating %sdirectory...' % name)
+ log(' "%s"' % path)
+
+ os.makedirs(path)
+
+ def _write_file(self, path, text):
+ """Create a file at the given path with given text.
+
+ This method overwrites any existing file.
+
+ """
+ _log.debug("Creating file...")
+ _log.debug(' "%s"' % path)
+ file = open(path, "w")
+ try:
+ file.write(text)
+ finally:
+ file.close()
+
+ def _set_up_target_dir(self, target_dir, append_to_search_path,
+ make_package):
+ """Set up a target directory.
+
+ Args:
+ target_dir: The path to the target directory to set up.
+ append_to_search_path: A boolean value of whether to append the
+ target directory to the sys.path search path.
+ make_package: A boolean value of whether to make the target
+ directory a package. This adds an __init__.py file
+ to the target directory -- allowing packages and
+ modules within the target directory to be imported
+ explicitly using dotted module names.
+
+ """
+ if not os.path.exists(target_dir):
+ self._create_directory(target_dir, "autoinstall target")
+
+ if append_to_search_path:
+ sys.path.append(target_dir)
+
+ if make_package:
+ init_path = os.path.join(target_dir, "__init__.py")
+ if not os.path.exists(init_path):
+ text = ("# This file is required for Python to search this "
+ "directory for modules.\n")
+ self._write_file(init_path, text)
+
+ def _create_scratch_directory_inner(self, prefix):
+ """Create a scratch directory without exception handling.
+
+ Creates a scratch directory inside the AutoInstaller temp
+ directory self._temp_dir, or inside a platform-dependent temp
+ directory if self._temp_dir is None. Returns the path to the
+ created scratch directory.
+
+ Raises:
+ OSError: [Errno 2] if the containing temp directory self._temp_dir
+ is not None and does not exist.
+
+ """
+ # The tempfile.mkdtemp() method function requires that the
+ # directory corresponding to the "dir" parameter already exist
+ # if it is not None.
+ scratch_dir = tempfile.mkdtemp(prefix=prefix, dir=self._temp_dir)
+ return scratch_dir
+
+ def _create_scratch_directory(self, target_name):
+ """Create a temporary scratch directory, and return its path.
+
+ The scratch directory is generated inside the temp directory
+ of this AutoInstaller instance. This method also creates the
+ temp directory if it does not already exist.
+
+ """
+ prefix = target_name + "_"
+ try:
+ scratch_dir = self._create_scratch_directory_inner(prefix)
+ except OSError:
+ # Handle case of containing temp directory not existing--
+ # OSError: [Errno 2] No such file or directory:...
+ temp_dir = self._temp_dir
+ if temp_dir is None or os.path.exists(temp_dir):
+ raise
+ # Else try again after creating the temp directory.
+ self._create_directory(temp_dir, "autoinstall temp")
+ scratch_dir = self._create_scratch_directory_inner(prefix)
+
+ return scratch_dir
+
+ def _url_downloaded_path(self, target_name):
+ """Return the path to the file containing the URL downloaded."""
+ filename = ".%s.url" % target_name
+ path = os.path.join(self._target_dir, filename)
+ return path
+
+ def _is_downloaded(self, target_name, url):
+ """Return whether a package version has been downloaded."""
+ version_path = self._url_downloaded_path(target_name)
+
+ _log.debug('Checking %s URL downloaded...' % target_name)
+ _log.debug(' "%s"' % version_path)
+
+ if not os.path.exists(version_path):
+ # Then no package version has been downloaded.
+ _log.debug("No URL file found.")
+ return False
+
+ file = open(version_path, "r")
+ try:
+ version = file.read()
+ finally:
+ file.close()
+
+ return version.strip() == url.strip()
+
+ def _record_url_downloaded(self, target_name, url):
+ """Record the URL downloaded to a file."""
+ version_path = self._url_downloaded_path(target_name)
+ _log.debug("Recording URL downloaded...")
+ _log.debug(' URL: "%s"' % url)
+ _log.debug(' To: "%s"' % version_path)
+
+ self._write_file(version_path, url)
+
+ def _extract_targz(self, path, scratch_dir):
+ # tarfile.extractall() extracts to a path without the
+ # trailing ".tar.gz".
+ target_basename = os.path.basename(path[:-len(".tar.gz")])
+ target_path = os.path.join(scratch_dir, target_basename)
+
+ self._log_transfer("Starting gunzip/extract...", path, target_path)
+
+ try:
+ tar_file = tarfile.open(path)
+ except tarfile.ReadError, err:
+ # Append existing Error message to new Error.
+ message = ("Could not open tar file: %s\n"
+ " The file probably does not have the correct format.\n"
+ " --> Inner message: %s"
+ % (path, err))
+ raise Exception(message)
+
+ try:
+ # This is helpful for debugging purposes.
+ _log.debug("Listing tar file contents...")
+ for name in tar_file.getnames():
+ _log.debug(' * "%s"' % name)
+ _log.debug("Extracting gzipped tar file...")
+ tar_file.extractall(target_path)
+ finally:
+ tar_file.close()
+
+ return target_path
+
+ # This is a replacement for ZipFile.extractall(), which is
+ # available in Python 2.6 but not in earlier versions.
+ def _extract_all(self, zip_file, target_dir):
+ self._log_transfer("Extracting zip file...", zip_file, target_dir)
+
+ # This is helpful for debugging purposes.
+ _log.debug("Listing zip file contents...")
+ for name in zip_file.namelist():
+ _log.debug(' * "%s"' % name)
+
+ for name in zip_file.namelist():
+ path = os.path.join(target_dir, name)
+ self._log_transfer("Extracting...", name, path)
+
+ if not os.path.basename(path):
+ # Then the path ends in a slash, so it is a directory.
+ self._create_directory(path)
+ continue
+ # Otherwise, it is a file.
+
+ try:
+ outfile = open(path, 'wb')
+ except IOError, err:
+ # Not all zip files seem to list the directories explicitly,
+ # so try again after creating the containing directory.
+ _log.debug("Got IOError: retrying after creating directory...")
+ dir = os.path.dirname(path)
+ self._create_directory(dir)
+ outfile = open(path, 'wb')
+
+ try:
+ outfile.write(zip_file.read(name))
+ finally:
+ outfile.close()
+
+ def _unzip(self, path, scratch_dir):
+ # zipfile.extractall() extracts to a path without the
+ # trailing ".zip".
+ target_basename = os.path.basename(path[:-len(".zip")])
+ target_path = os.path.join(scratch_dir, target_basename)
+
+ self._log_transfer("Starting unzip...", path, target_path)
+
+ try:
+ zip_file = zipfile.ZipFile(path, "r")
+ except zipfile.BadZipfile, err:
+ message = ("Could not open zip file: %s\n"
+ " --> Inner message: %s"
+ % (path, err))
+ raise Exception(message)
+
+ try:
+ self._extract_all(zip_file, scratch_dir)
+ finally:
+ zip_file.close()
+
+ return target_path
+
+ def _prepare_package(self, path, scratch_dir):
+ """Prepare a package for use, if necessary, and return the new path.
+
+ For example, this method unzips zipped files and extracts
+ tar files.
+
+ Args:
+ path: The path to the downloaded URL contents.
+ scratch_dir: The scratch directory. Note that the scratch
+ directory contains the file designated by the
+ path parameter.
+
+ """
+ # FIXME: Add other natural extensions.
+ if path.endswith(".zip"):
+ new_path = self._unzip(path, scratch_dir)
+ elif path.endswith(".tar.gz"):
+ new_path = self._extract_targz(path, scratch_dir)
+ else:
+ # No preparation is needed.
+ new_path = path
+
+ return new_path
+
+ def _download_to_stream(self, url, stream):
+ """Download an URL to a stream, and return the number of bytes."""
+ try:
+ netstream = urllib.urlopen(url)
+ except IOError, err:
+ # Append existing Error message to new Error.
+ message = ('Could not download Python modules from URL "%s".\n'
+ " Make sure you are connected to the internet.\n"
+ " You must be connected to the internet when "
+ "downloading needed modules for the first time.\n"
+ " --> Inner message: %s"
+ % (url, err))
+ raise IOError(message)
+ code = 200
+ if hasattr(netstream, "getcode"):
+ code = netstream.getcode()
+ if not 200 <= code < 300:
+ raise ValueError("HTTP Error code %s" % code)
+
+ BUFSIZE = 2**13 # 8KB
+ bytes = 0
+ while True:
+ data = netstream.read(BUFSIZE)
+ if not data:
+ break
+ stream.write(data)
+ bytes += len(data)
+ netstream.close()
+ return bytes
+
+ def _download(self, url, scratch_dir):
+ """Download URL contents, and return the download path."""
+ url_path = urlparse.urlsplit(url)[2]
+ url_path = os.path.normpath(url_path) # Removes trailing slash.
+ target_filename = os.path.basename(url_path)
+ target_path = os.path.join(scratch_dir, target_filename)
+
+ self._log_transfer("Starting download...", url, target_path)
+
+ stream = file(target_path, "wb")
+ bytes = self._download_to_stream(url, stream)
+ stream.close()
+
+ _log.debug("Downloaded %s bytes." % bytes)
+
+ return target_path
+
+ def _install(self, scratch_dir, package_name, target_path, url,
+ url_subpath):
+ """Install a python package from an URL.
+
+ This internal method overwrites the target path if the target
+ path already exists.
+
+ """
+ path = self._download(url=url, scratch_dir=scratch_dir)
+ path = self._prepare_package(path, scratch_dir)
+
+ if url_subpath is None:
+ source_path = path
+ else:
+ source_path = os.path.join(path, url_subpath)
+
+ if os.path.exists(target_path):
+ _log.debug('Refreshing install: deleting "%s".' % target_path)
+ if os.path.isdir(target_path):
+ shutil.rmtree(target_path)
+ else:
+ os.remove(target_path)
+
+ self._log_transfer("Moving files into place...", source_path, target_path)
+
+ # The shutil.move() command creates intermediate directories if they
+ # do not exist, but we do not rely on this behavior since we
+ # need to create the __init__.py file anyway.
+ shutil.move(source_path, target_path)
+
+ self._record_url_downloaded(package_name, url)
+
+ def install(self, url, should_refresh=False, target_name=None,
+ url_subpath=None):
+ """Install a python package from an URL.
+
+ Args:
+ url: The URL from which to download the package.
+
+ Optional Args:
+ should_refresh: A boolean value of whether the package should be
+ downloaded again if the package is already present.
+ target_name: The name of the folder or file in the autoinstaller
+ target directory at which the package should be
+ installed. Defaults to the base name of the
+ URL sub-path. This parameter must be provided if
+ the URL sub-path is not specified.
+ url_subpath: The relative path of the URL directory that should
+ be installed. Defaults to the full directory, or
+ the entire URL contents.
+
+ """
+ if target_name is None:
+ if not url_subpath:
+ raise ValueError('The "target_name" parameter must be '
+ 'provided if the "url_subpath" parameter '
+ "is not provided.")
+ # Remove any trailing slashes.
+ url_subpath = os.path.normpath(url_subpath)
+ target_name = os.path.basename(url_subpath)
+
+ target_path = os.path.join(self._target_dir, target_name)
+ if not should_refresh and self._is_downloaded(target_name, url):
+ _log.debug('URL for %s already downloaded. Skipping...'
+ % target_name)
+ _log.debug(' "%s"' % url)
+ return
+
+ self._log_transfer("Auto-installing package: %s" % target_name,
+ url, target_path, log_method=_log.info)
+
+ # The scratch directory is where we will download and prepare
+ # files specific to this install until they are ready to move
+ # into place.
+ scratch_dir = self._create_scratch_directory(target_name)
+
+ try:
+ self._install(package_name=target_name,
+ target_path=target_path,
+ scratch_dir=scratch_dir,
+ url=url,
+ url_subpath=url_subpath)
+ except Exception, err:
+ # Append existing Error message to new Error.
+ message = ("Error auto-installing the %s package to:\n"
+ ' "%s"\n'
+ " --> Inner message: %s"
+ % (target_name, target_path, err))
+ raise Exception(message)
+ finally:
+ _log.debug('Cleaning up: deleting "%s".' % scratch_dir)
+ shutil.rmtree(scratch_dir)
+ _log.debug('Auto-installed %s to:' % target_name)
+ _log.debug(' "%s"' % target_path)
+
+
+if __name__=="__main__":
+
+ # Configure the autoinstall logger to log DEBUG messages for
+ # development testing purposes.
+ console = logging.StreamHandler()
+
+ formatter = logging.Formatter('%(name)s: %(levelname)-8s %(message)s')
+ console.setFormatter(formatter)
+ _log.addHandler(console)
+ _log.setLevel(logging.DEBUG)
+
+ # Use a more visible temp directory for debug purposes.
+ this_dir = os.path.dirname(__file__)
+ target_dir = os.path.join(this_dir, "autoinstalled")
+ temp_dir = os.path.join(target_dir, "Temp")
+
+ installer = AutoInstaller(target_dir=target_dir,
+ temp_dir=temp_dir)
+
+ installer.install(should_refresh=False,
+ target_name="pep8.py",
+ url="http://pypi.python.org/packages/source/p/pep8/pep8-0.5.0.tar.gz#md5=512a818af9979290cd619cce8e9c2e2b",
+ url_subpath="pep8-0.5.0/pep8.py")
+ installer.install(should_refresh=False,
+ target_name="mechanize",
+ url="http://pypi.python.org/packages/source/m/mechanize/mechanize-0.1.11.zip",
+ url_subpath="mechanize")
+
diff --git a/WebKitTools/Scripts/webkitpy/common/system/deprecated_logging.py b/WebKitTools/Scripts/webkitpy/common/system/deprecated_logging.py
new file mode 100644
index 0000000..ba1c5eb
--- /dev/null
+++ b/WebKitTools/Scripts/webkitpy/common/system/deprecated_logging.py
@@ -0,0 +1,85 @@
+# Copyright (c) 2009, Google Inc. All rights reserved.
+# Copyright (c) 2009 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# * Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following disclaimer
+# in the documentation and/or other materials provided with the
+# distribution.
+# * Neither the name of Google Inc. nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+#
+# WebKit's Python module for logging
+# This module is now deprecated in favor of python's built-in logging.py.
+
+import os
+import sys
+
+def log(string):
+ print >> sys.stderr, string
+
+def error(string):
+ log("ERROR: %s" % string)
+ exit(1)
+
+# Simple class to split output between multiple destinations
+class tee:
+ def __init__(self, *files):
+ self.files = files
+
+ def write(self, string):
+ for file in self.files:
+ file.write(string)
+
+class OutputTee:
+ def __init__(self):
+ self._original_stdout = None
+ self._original_stderr = None
+ self._files_for_output = []
+
+ def add_log(self, path):
+ log_file = self._open_log_file(path)
+ self._files_for_output.append(log_file)
+ self._tee_outputs_to_files(self._files_for_output)
+ return log_file
+
+ def remove_log(self, log_file):
+ self._files_for_output.remove(log_file)
+ self._tee_outputs_to_files(self._files_for_output)
+ log_file.close()
+
+ @staticmethod
+ def _open_log_file(log_path):
+ (log_directory, log_name) = os.path.split(log_path)
+ if log_directory and not os.path.exists(log_directory):
+ os.makedirs(log_directory)
+ return open(log_path, 'a+')
+
+ def _tee_outputs_to_files(self, files):
+ if not self._original_stdout:
+ self._original_stdout = sys.stdout
+ self._original_stderr = sys.stderr
+ if files and len(files):
+ sys.stdout = tee(self._original_stdout, *files)
+ sys.stderr = tee(self._original_stderr, *files)
+ else:
+ sys.stdout = self._original_stdout
+ sys.stderr = self._original_stderr
diff --git a/WebKitTools/Scripts/webkitpy/common/system/deprecated_logging_unittest.py b/WebKitTools/Scripts/webkitpy/common/system/deprecated_logging_unittest.py
new file mode 100644
index 0000000..2b71803
--- /dev/null
+++ b/WebKitTools/Scripts/webkitpy/common/system/deprecated_logging_unittest.py
@@ -0,0 +1,61 @@
+# Copyright (C) 2009 Google Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# * Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following disclaimer
+# in the documentation and/or other materials provided with the
+# distribution.
+# * Neither the name of Google Inc. nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import os
+import subprocess
+import StringIO
+import tempfile
+import unittest
+
+from webkitpy.common.system.executive import ScriptError
+from webkitpy.common.system.deprecated_logging import *
+
+class LoggingTest(unittest.TestCase):
+
+ def assert_log_equals(self, log_input, expected_output):
+ original_stderr = sys.stderr
+ test_stderr = StringIO.StringIO()
+ sys.stderr = test_stderr
+
+ try:
+ log(log_input)
+ actual_output = test_stderr.getvalue()
+ finally:
+ sys.stderr = original_stderr
+
+ self.assertEquals(actual_output, expected_output, "log(\"%s\") expected: %s actual: %s" % (log_input, expected_output, actual_output))
+
+ def test_log(self):
+ self.assert_log_equals("test", "test\n")
+
+ # Test that log() does not throw an exception when passed an object instead of a string.
+ self.assert_log_equals(ScriptError(message="ScriptError"), "ScriptError\n")
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/WebKitTools/Scripts/webkitpy/common/system/executive.py b/WebKitTools/Scripts/webkitpy/common/system/executive.py
new file mode 100644
index 0000000..b6126e4
--- /dev/null
+++ b/WebKitTools/Scripts/webkitpy/common/system/executive.py
@@ -0,0 +1,209 @@
+# Copyright (c) 2009, Google Inc. All rights reserved.
+# Copyright (c) 2009 Apple Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# * Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following disclaimer
+# in the documentation and/or other materials provided with the
+# distribution.
+# * Neither the name of Google Inc. nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+try:
+ # This API exists only in Python 2.6 and higher. :(
+ import multiprocessing
+except ImportError:
+ multiprocessing = None
+
+import os
+import platform
+import StringIO
+import signal
+import subprocess
+import sys
+
+from webkitpy.common.system.deprecated_logging import tee
+
+
+class ScriptError(Exception):
+
+ def __init__(self,
+ message=None,
+ script_args=None,
+ exit_code=None,
+ output=None,
+ cwd=None):
+ if not message:
+ message = 'Failed to run "%s"' % script_args
+ if exit_code:
+ message += " exit_code: %d" % exit_code
+ if cwd:
+ message += " cwd: %s" % cwd
+
+ Exception.__init__(self, message)
+ self.script_args = script_args # 'args' is already used by Exception
+ self.exit_code = exit_code
+ self.output = output
+ self.cwd = cwd
+
+ def message_with_output(self, output_limit=500):
+ if self.output:
+ if output_limit and len(self.output) > output_limit:
+ return "%s\nLast %s characters of output:\n%s" % \
+ (self, output_limit, self.output[-output_limit:])
+ return "%s\n%s" % (self, self.output)
+ return str(self)
+
+ def command_name(self):
+ command_path = self.script_args
+ if type(command_path) is list:
+ command_path = command_path[0]
+ return os.path.basename(command_path)
+
+
+def run_command(*args, **kwargs):
+ # FIXME: This should not be a global static.
+ # New code should use Executive.run_command directly instead
+ return Executive().run_command(*args, **kwargs)
+
+
+class Executive(object):
+
+ def _run_command_with_teed_output(self, args, teed_output):
+ child_process = subprocess.Popen(args,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT)
+
+ # Use our own custom wait loop because Popen ignores a tee'd
+ # stderr/stdout.
+ # FIXME: This could be improved not to flatten output to stdout.
+ while True:
+ output_line = child_process.stdout.readline()
+ if output_line == "" and child_process.poll() != None:
+ return child_process.poll()
+ teed_output.write(output_line)
+
+ def run_and_throw_if_fail(self, args, quiet=False):
+ # Cache the child's output locally so it can be used for error reports.
+ child_out_file = StringIO.StringIO()
+ tee_stdout = sys.stdout
+ if quiet:
+ dev_null = open(os.devnull, "w")
+ tee_stdout = dev_null
+ child_stdout = tee(child_out_file, tee_stdout)
+ exit_code = self._run_command_with_teed_output(args, child_stdout)
+ if quiet:
+ dev_null.close()
+
+ child_output = child_out_file.getvalue()
+ child_out_file.close()
+
+ if exit_code:
+ raise ScriptError(script_args=args,
+ exit_code=exit_code,
+ output=child_output)
+ return child_output
+
+ def cpu_count(self):
+ if multiprocessing:
+ return multiprocessing.cpu_count()
+ # Darn. We don't have the multiprocessing package.
+ system_name = platform.system()
+ if system_name == "Darwin":
+ return int(self.run_command(["sysctl", "-n", "hw.ncpu"]))
+ elif system_name == "Windows":
+ return int(os.environ.get('NUMBER_OF_PROCESSORS', 1))
+ elif system_name == "Linux":
+ num_cores = os.sysconf("SC_NPROCESSORS_ONLN")
+ if isinstance(num_cores, int) and num_cores > 0:
+ return num_cores
+ # This quantity is a lie but probably a reasonable guess for modern
+ # machines.
+ return 2
+
+ def kill_process(self, pid):
+ if platform.system() == "Windows":
+ # According to http://docs.python.org/library/os.html
+ # os.kill isn't available on Windows. However, when I tried it
+ # using Cygwin, it worked fine. We should investigate whether
+ # we need this platform specific code here.
+ subprocess.call(('taskkill.exe', '/f', '/pid', str(pid)),
+ stdin=open(os.devnull, 'r'),
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE)
+ return
+ os.kill(pid, signal.SIGKILL)
+
+ # Error handlers do not need to be static methods once all callers are
+ # updated to use an Executive object.
+
+ @staticmethod
+ def default_error_handler(error):
+ raise error
+
+ @staticmethod
+ def ignore_error(error):
+ pass
+
+ # FIXME: This should be merged with run_and_throw_if_fail
+
+ def run_command(self,
+ args,
+ cwd=None,
+ input=None,
+ error_handler=None,
+ return_exit_code=False,
+ return_stderr=True):
+ if hasattr(input, 'read'): # Check if the input is a file.
+ stdin = input
+ string_to_communicate = None
+ else:
+ stdin = None
+ if input:
+ stdin = subprocess.PIPE
+ # string_to_communicate seems to need to be a str for proper
+ # communication with shell commands.
+ # See https://bugs.webkit.org/show_bug.cgi?id=37528
+ # For an example of a regresion caused by passing a unicode string through.
+ string_to_communicate = str(input)
+ if return_stderr:
+ stderr = subprocess.STDOUT
+ else:
+ stderr = None
+
+ process = subprocess.Popen(args,
+ stdin=stdin,
+ stdout=subprocess.PIPE,
+ stderr=stderr,
+ cwd=cwd)
+ output = process.communicate(string_to_communicate)[0]
+ exit_code = process.wait()
+
+ if return_exit_code:
+ return exit_code
+
+ if exit_code:
+ script_error = ScriptError(script_args=args,
+ exit_code=exit_code,
+ output=output,
+ cwd=cwd)
+ (error_handler or self.default_error_handler)(script_error)
+ return output
diff --git a/WebKitTools/Scripts/webkitpy/common/system/executive_unittest.py b/WebKitTools/Scripts/webkitpy/common/system/executive_unittest.py
new file mode 100644
index 0000000..ac380f8
--- /dev/null
+++ b/WebKitTools/Scripts/webkitpy/common/system/executive_unittest.py
@@ -0,0 +1,42 @@
+# Copyright (C) 2009 Google Inc. All rights reserved.
+# Copyright (C) 2009 Daniel Bates (dbates@intudata.com). All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# * Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following disclaimer
+# in the documentation and/or other materials provided with the
+# distribution.
+# * Neither the name of Google Inc. nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import unittest
+
+from webkitpy.common.system.executive import Executive, run_command
+
+class ExecutiveTest(unittest.TestCase):
+
+ def test_run_command_with_bad_command(self):
+ def run_bad_command():
+ run_command(["foo_bar_command_blah"], error_handler=Executive.ignore_error, return_exit_code=True)
+ self.failUnlessRaises(OSError, run_bad_command)
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/WebKitTools/Scripts/webkitpy/common/system/logtesting.py b/WebKitTools/Scripts/webkitpy/common/system/logtesting.py
new file mode 100644
index 0000000..e361cb5
--- /dev/null
+++ b/WebKitTools/Scripts/webkitpy/common/system/logtesting.py
@@ -0,0 +1,258 @@
+# Copyright (C) 2010 Chris Jerdonek (cjerdonek@webkit.org)
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1. Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# 2. Redistributions in binary form must reproduce the above copyright
+# notice, this list of conditions and the following disclaimer in the
+# documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS'' AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+"""Supports the unit-testing of logging code.
+
+Provides support for unit-testing messages logged using the built-in
+logging module.
+
+Inherit from the LoggingTestCase class for basic testing needs. For
+more advanced needs (e.g. unit-testing methods that configure logging),
+see the TestLogStream class, and perhaps also the LogTesting class.
+
+"""
+
+import logging
+import unittest
+
+
+class TestLogStream(object):
+
+ """Represents a file-like object for unit-testing logging.
+
+ This is meant for passing to the logging.StreamHandler constructor.
+ Log messages captured by instances of this object can be tested
+ using self.assertMessages() below.
+
+ """
+
+ def __init__(self, test_case):
+ """Create an instance.
+
+ Args:
+ test_case: A unittest.TestCase instance.
+
+ """
+ self._test_case = test_case
+ self.messages = []
+ """A list of log messages written to the stream."""
+
+ # Python documentation says that any object passed to the StreamHandler
+ # constructor should support write() and flush():
+ #
+ # http://docs.python.org/library/logging.html#module-logging.handlers
+ def write(self, message):
+ self.messages.append(message)
+
+ def flush(self):
+ pass
+
+ def assertMessages(self, messages):
+ """Assert that the given messages match the logged messages.
+
+ messages: A list of log message strings.
+
+ """
+ self._test_case.assertEquals(messages, self.messages)
+
+
+class LogTesting(object):
+
+ """Supports end-to-end unit-testing of log messages.
+
+ Sample usage:
+
+ class SampleTest(unittest.TestCase):
+
+ def setUp(self):
+ self._log = LogTesting.setUp(self) # Turn logging on.
+
+ def tearDown(self):
+ self._log.tearDown() # Turn off and reset logging.
+
+ def test_logging_in_some_method(self):
+ call_some_method() # Contains calls to _log.info(), etc.
+
+ # Check the resulting log messages.
+ self._log.assertMessages(["INFO: expected message #1",
+ "WARNING: expected message #2"])
+
+ """
+
+ def __init__(self, test_stream, handler):
+ """Create an instance.
+
+ This method should never be called directly. Instances should
+ instead be created using the static setUp() method.
+
+ Args:
+ test_stream: A TestLogStream instance.
+ handler: The handler added to the logger.
+
+ """
+ self._test_stream = test_stream
+ self._handler = handler
+
+ @staticmethod
+ def _getLogger():
+ """Return the logger being tested."""
+ # It is possible we might want to return something other than
+ # the root logger in some special situation. For now, the
+ # root logger seems to suffice.
+ return logging.getLogger()
+
+ @staticmethod
+ def setUp(test_case, logging_level=logging.INFO):
+ """Configure logging for unit testing.
+
+ Configures the root logger to log to a testing log stream.
+ Only messages logged at or above the given level are logged
+ to the stream. Messages logged to the stream are formatted
+ in the following way, for example--
+
+ "INFO: This is a test log message."
+
+ This method should normally be called in the setUp() method
+ of a unittest.TestCase. See the docstring of this class
+ for more details.
+
+ Returns:
+ A LogTesting instance.
+
+ Args:
+ test_case: A unittest.TestCase instance.
+ logging_level: An integer logging level that is the minimum level
+ of log messages you would like to test.
+
+ """
+ stream = TestLogStream(test_case)
+ handler = logging.StreamHandler(stream)
+ handler.setLevel(logging_level)
+ formatter = logging.Formatter("%(levelname)s: %(message)s")
+ handler.setFormatter(formatter)
+
+ # Notice that we only change the root logger by adding a handler
+ # to it. In particular, we do not reset its level using
+ # logger.setLevel(). This ensures that we have not interfered
+ # with how the code being tested may have configured the root
+ # logger.
+ logger = LogTesting._getLogger()
+ logger.addHandler(handler)
+
+ return LogTesting(stream, handler)
+
+ def tearDown(self):
+ """Assert there are no remaining log messages, and reset logging.
+
+ This method asserts that there are no more messages in the array of
+ log messages, and then restores logging to its original state.
+ This method should normally be called in the tearDown() method of a
+ unittest.TestCase. See the docstring of this class for more details.
+
+ """
+ self.assertMessages([])
+ logger = LogTesting._getLogger()
+ logger.removeHandler(self._handler)
+
+ def messages(self):
+ """Return the current list of log messages."""
+ return self._test_stream.messages
+
+ # FIXME: Add a clearMessages() method for cases where the caller
+ # deliberately doesn't want to assert every message.
+
+ # We clear the log messages after asserting since they are no longer
+ # needed after asserting. This serves two purposes: (1) it simplifies
+ # the calling code when we want to check multiple logging calls in a
+ # single test method, and (2) it lets us check in the tearDown() method
+ # that there are no remaining log messages to be asserted.
+ #
+ # The latter ensures that no extra log messages are getting logged that
+ # the caller might not be aware of or may have forgotten to check for.
+ # This gets us a bit more mileage out of our tests without writing any
+ # additional code.
+ def assertMessages(self, messages):
+ """Assert the current array of log messages, and clear its contents.
+
+ Args:
+ messages: A list of log message strings.
+
+ """
+ try:
+ self._test_stream.assertMessages(messages)
+ finally:
+ # We want to clear the array of messages even in the case of
+ # an Exception (e.g. an AssertionError). Otherwise, another
+ # AssertionError can occur in the tearDown() because the
+ # array might not have gotten emptied.
+ self._test_stream.messages = []
+
+
+# This class needs to inherit from unittest.TestCase. Otherwise, the
+# setUp() and tearDown() methods will not get fired for test case classes
+# that inherit from this class -- even if the class inherits from *both*
+# unittest.TestCase and LoggingTestCase.
+#
+# FIXME: Rename this class to LoggingTestCaseBase to be sure that
+# the unittest module does not interpret this class as a unittest
+# test case itself.
+class LoggingTestCase(unittest.TestCase):
+
+ """Supports end-to-end unit-testing of log messages.
+
+ Sample usage:
+
+ class SampleTest(LoggingTestCase):
+
+ def test_logging_in_some_method(self):
+ call_some_method() # Contains calls to _log.info(), etc.
+
+ # Check the resulting log messages.
+ self.assertLog(["INFO: expected message #1",
+ "WARNING: expected message #2"])
+
+ """
+
+ def setUp(self):
+ self._log = LogTesting.setUp(self)
+
+ def tearDown(self):
+ self._log.tearDown()
+
+ def logMessages(self):
+ """Return the current list of log messages."""
+ return self._log.messages()
+
+ # FIXME: Add a clearMessages() method for cases where the caller
+ # deliberately doesn't want to assert every message.
+
+ # See the code comments preceding LogTesting.assertMessages() for
+ # an explanation of why we clear the array of messages after
+ # asserting its contents.
+ def assertLog(self, messages):
+ """Assert the current array of log messages, and clear its contents.
+
+ Args:
+ messages: A list of log message strings.
+
+ """
+ self._log.assertMessages(messages)
diff --git a/WebKitTools/Scripts/webkitpy/common/system/logutils.py b/WebKitTools/Scripts/webkitpy/common/system/logutils.py
new file mode 100644
index 0000000..cd4e60f
--- /dev/null
+++ b/WebKitTools/Scripts/webkitpy/common/system/logutils.py
@@ -0,0 +1,207 @@
+# Copyright (C) 2010 Chris Jerdonek (cjerdonek@webkit.org)
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1. Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# 2. Redistributions in binary form must reproduce the above copyright
+# notice, this list of conditions and the following disclaimer in the
+# documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS'' AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+"""Supports webkitpy logging."""
+
+# FIXME: Move this file to webkitpy/python24 since logging needs to
+# be configured prior to running version-checking code.
+
+import logging
+import os
+import sys
+
+import webkitpy
+
+
+_log = logging.getLogger(__name__)
+
+# We set these directory paths lazily in get_logger() below.
+_scripts_dir = ""
+"""The normalized, absolute path to the ...Scripts directory."""
+
+_webkitpy_dir = ""
+"""The normalized, absolute path to the ...Scripts/webkitpy directory."""
+
+
+def _normalize_path(path):
+ """Return the given path normalized.
+
+ Converts a path to an absolute path, removes any trailing slashes,
+ removes any extension, and lower-cases it.
+
+ """
+ path = os.path.abspath(path)
+ path = os.path.normpath(path)
+ path = os.path.splitext(path)[0] # Remove the extension, if any.
+ path = path.lower()
+
+ return path
+
+
+# Observe that the implementation of this function does not require
+# the use of any hard-coded strings like "webkitpy", etc.
+#
+# The main benefit this function has over using--
+#
+# _log = logging.getLogger(__name__)
+#
+# is that get_logger() returns the same value even if __name__ is
+# "__main__" -- i.e. even if the module is the script being executed
+# from the command-line.
+def get_logger(path):
+ """Return a logging.logger for the given path.
+
+ Returns:
+ A logger whose name is the name of the module corresponding to
+ the given path. If the module is in webkitpy, the name is
+ the fully-qualified dotted module name beginning with webkitpy....
+ Otherwise, the name is the base name of the module (i.e. without
+ any dotted module name prefix).
+
+ Args:
+ path: The path of the module. Normally, this parameter should be
+ the __file__ variable of the module.
+
+ Sample usage:
+
+ import webkitpy.common.system.logutils as logutils
+
+ _log = logutils.get_logger(__file__)
+
+ """
+ # Since we assign to _scripts_dir and _webkitpy_dir in this function,
+ # we need to declare them global.
+ global _scripts_dir
+ global _webkitpy_dir
+
+ path = _normalize_path(path)
+
+ # Lazily evaluate _webkitpy_dir and _scripts_dir.
+ if not _scripts_dir:
+ # The normalized, absolute path to ...Scripts/webkitpy/__init__.
+ webkitpy_path = _normalize_path(webkitpy.__file__)
+
+ _webkitpy_dir = os.path.split(webkitpy_path)[0]
+ _scripts_dir = os.path.split(_webkitpy_dir)[0]
+
+ if path.startswith(_webkitpy_dir):
+ # Remove the initial Scripts directory portion, so the path
+ # starts with /webkitpy, for example "/webkitpy/init/logutils".
+ path = path[len(_scripts_dir):]
+
+ parts = []
+ while True:
+ (path, tail) = os.path.split(path)
+ if not tail:
+ break
+ parts.insert(0, tail)
+
+ logger_name = ".".join(parts) # For example, webkitpy.common.system.logutils.
+ else:
+ # The path is outside of webkitpy. Default to the basename
+ # without the extension.
+ basename = os.path.basename(path)
+ logger_name = os.path.splitext(basename)[0]
+
+ return logging.getLogger(logger_name)
+
+
+def _default_handlers(stream):
+ """Return a list of the default logging handlers to use.
+
+ Args:
+ stream: See the configure_logging() docstring.
+
+ """
+ # Create the filter.
+ def should_log(record):
+ """Return whether a logging.LogRecord should be logged."""
+ # FIXME: Enable the logging of autoinstall messages once
+ # autoinstall is adjusted. Currently, autoinstall logs
+ # INFO messages when importing already-downloaded packages,
+ # which is too verbose.
+ if record.name.startswith("webkitpy.thirdparty.autoinstall"):
+ return False
+ return True
+
+ logging_filter = logging.Filter()
+ logging_filter.filter = should_log
+
+ # Create the handler.
+ handler = logging.StreamHandler(stream)
+ formatter = logging.Formatter("%(name)s: [%(levelname)s] %(message)s")
+ handler.setFormatter(formatter)
+ handler.addFilter(logging_filter)
+
+ return [handler]
+
+
+def configure_logging(logging_level=None, logger=None, stream=None,
+ handlers=None):
+ """Configure logging for standard purposes.
+
+ Returns:
+ A list of references to the logging handlers added to the root
+ logger. This allows the caller to later remove the handlers
+ using logger.removeHandler. This is useful primarily during unit
+ testing where the caller may want to configure logging temporarily
+ and then undo the configuring.
+
+ Args:
+ logging_level: The minimum logging level to log. Defaults to
+ logging.INFO.
+ logger: A logging.logger instance to configure. This parameter
+ should be used only in unit tests. Defaults to the
+ root logger.
+ stream: A file-like object to which to log used in creating the default
+ handlers. The stream must define an "encoding" data attribute,
+ or else logging raises an error. Defaults to sys.stderr.
+ handlers: A list of logging.Handler instances to add to the logger
+ being configured. If this parameter is provided, then the
+ stream parameter is not used.
+
+ """
+ # If the stream does not define an "encoding" data attribute, the
+ # logging module can throw an error like the following:
+ #
+ # Traceback (most recent call last):
+ # File "/System/Library/Frameworks/Python.framework/Versions/2.6/...
+ # lib/python2.6/logging/__init__.py", line 761, in emit
+ # self.stream.write(fs % msg.encode(self.stream.encoding))
+ # LookupError: unknown encoding: unknown
+ if logging_level is None:
+ logging_level = logging.INFO
+ if logger is None:
+ logger = logging.getLogger()
+ if stream is None:
+ stream = sys.stderr
+ if handlers is None:
+ handlers = _default_handlers(stream)
+
+ logger.setLevel(logging_level)
+
+ for handler in handlers:
+ logger.addHandler(handler)
+
+ _log.debug("Debug logging enabled.")
+
+ return handlers
diff --git a/WebKitTools/Scripts/webkitpy/common/system/logutils_unittest.py b/WebKitTools/Scripts/webkitpy/common/system/logutils_unittest.py
new file mode 100644
index 0000000..a4a6496
--- /dev/null
+++ b/WebKitTools/Scripts/webkitpy/common/system/logutils_unittest.py
@@ -0,0 +1,142 @@
+# Copyright (C) 2010 Chris Jerdonek (cjerdonek@webkit.org)
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1. Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# 2. Redistributions in binary form must reproduce the above copyright
+# notice, this list of conditions and the following disclaimer in the
+# documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS'' AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+"""Unit tests for logutils.py."""
+
+import logging
+import os
+import unittest
+
+from webkitpy.common.system.logtesting import LogTesting
+from webkitpy.common.system.logtesting import TestLogStream
+import webkitpy.common.system.logutils as logutils
+
+
+class GetLoggerTest(unittest.TestCase):
+
+ """Tests get_logger()."""
+
+ def test_get_logger_in_webkitpy(self):
+ logger = logutils.get_logger(__file__)
+ self.assertEquals(logger.name, "webkitpy.common.system.logutils_unittest")
+
+ def test_get_logger_not_in_webkitpy(self):
+ # Temporarily change the working directory so that we
+ # can test get_logger() for a path outside of webkitpy.
+ working_directory = os.getcwd()
+ root_dir = "/"
+ os.chdir(root_dir)
+
+ logger = logutils.get_logger("/WebKitTools/Scripts/test-webkitpy")
+ self.assertEquals(logger.name, "test-webkitpy")
+
+ logger = logutils.get_logger("/WebKitTools/Scripts/test-webkitpy.py")
+ self.assertEquals(logger.name, "test-webkitpy")
+
+ os.chdir(working_directory)
+
+
+class ConfigureLoggingTestBase(unittest.TestCase):
+
+ """Base class for configure_logging() unit tests."""
+
+ def _logging_level(self):
+ raise Exception("Not implemented.")
+
+ def setUp(self):
+ log_stream = TestLogStream(self)
+
+ # Use a logger other than the root logger or one prefixed with
+ # "webkitpy." so as not to conflict with test-webkitpy logging.
+ logger = logging.getLogger("unittest")
+
+ # Configure the test logger not to pass messages along to the
+ # root logger. This prevents test messages from being
+ # propagated to loggers used by test-webkitpy logging (e.g.
+ # the root logger).
+ logger.propagate = False
+
+ logging_level = self._logging_level()
+ self._handlers = logutils.configure_logging(logging_level=logging_level,
+ logger=logger,
+ stream=log_stream)
+ self._log = logger
+ self._log_stream = log_stream
+
+ def tearDown(self):
+ """Reset logging to its original state.
+
+ This method ensures that the logging configuration set up
+ for a unit test does not affect logging in other unit tests.
+
+ """
+ logger = self._log
+ for handler in self._handlers:
+ logger.removeHandler(handler)
+
+ def _assert_log_messages(self, messages):
+ """Assert that the logged messages equal the given messages."""
+ self._log_stream.assertMessages(messages)
+
+
+class ConfigureLoggingTest(ConfigureLoggingTestBase):
+
+ """Tests configure_logging() with the default logging level."""
+
+ def _logging_level(self):
+ return None
+
+ def test_info_message(self):
+ self._log.info("test message")
+ self._assert_log_messages(["unittest: [INFO] test message\n"])
+
+ def test_below_threshold_message(self):
+ # We test the boundary case of a logging level equal to 19.
+ # In practice, we will probably only be calling log.debug(),
+ # which corresponds to a logging level of 10.
+ level = logging.INFO - 1 # Equals 19.
+ self._log.log(level, "test message")
+ self._assert_log_messages([])
+
+ def test_two_messages(self):
+ self._log.info("message1")
+ self._log.info("message2")
+ self._assert_log_messages(["unittest: [INFO] message1\n",
+ "unittest: [INFO] message2\n"])
+
+
+class ConfigureLoggingCustomLevelTest(ConfigureLoggingTestBase):
+
+ """Tests configure_logging() with a custom logging level."""
+
+ _level = 36
+
+ def _logging_level(self):
+ return self._level
+
+ def test_logged_message(self):
+ self._log.log(self._level, "test message")
+ self._assert_log_messages(["unittest: [Level 36] test message\n"])
+
+ def test_below_threshold_message(self):
+ self._log.log(self._level - 1, "test message")
+ self._assert_log_messages([])
diff --git a/WebKitTools/Scripts/webkitpy/common/system/ospath.py b/WebKitTools/Scripts/webkitpy/common/system/ospath.py
new file mode 100644
index 0000000..aed7a3d
--- /dev/null
+++ b/WebKitTools/Scripts/webkitpy/common/system/ospath.py
@@ -0,0 +1,83 @@
+# Copyright (C) 2010 Chris Jerdonek (cjerdonek@webkit.org)
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1. Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# 2. Redistributions in binary form must reproduce the above copyright
+# notice, this list of conditions and the following disclaimer in the
+# documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS'' AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+"""Contains a substitute for Python 2.6's os.path.relpath()."""
+
+import os
+
+
+# This function is a replacement for os.path.relpath(), which is only
+# available in Python 2.6:
+#
+# http://docs.python.org/library/os.path.html#os.path.relpath
+#
+# It should behave essentially the same as os.path.relpath(), except for
+# returning None on paths not contained in abs_start_path.
+def relpath(path, start_path, os_path_abspath=None):
+ """Return a path relative to the given start path, or None.
+
+ Returns None if the path is not contained in the directory start_path.
+
+ Args:
+ path: An absolute or relative path to convert to a relative path.
+ start_path: The path relative to which the given path should be
+ converted.
+ os_path_abspath: A replacement function for unit testing. This
+ function should strip trailing slashes just like
+ os.path.abspath(). Defaults to os.path.abspath.
+
+ """
+ if os_path_abspath is None:
+ os_path_abspath = os.path.abspath
+
+ # Since os_path_abspath() calls os.path.normpath()--
+ #
+ # (see http://docs.python.org/library/os.path.html#os.path.abspath )
+ #
+ # it also removes trailing slashes and converts forward and backward
+ # slashes to the preferred slash os.sep.
+ start_path = os_path_abspath(start_path)
+ path = os_path_abspath(path)
+
+ if not path.lower().startswith(start_path.lower()):
+ # Then path is outside the directory given by start_path.
+ return None
+
+ rel_path = path[len(start_path):]
+
+ if not rel_path:
+ # Then the paths are the same.
+ pass
+ elif rel_path[0] == os.sep:
+ # It is probably sufficient to remove just the first character
+ # since os.path.normpath() collapses separators, but we use
+ # lstrip() just to be sure.
+ rel_path = rel_path.lstrip(os.sep)
+ else:
+ # We are in the case typified by the following example:
+ #
+ # start_path = "/tmp/foo"
+ # path = "/tmp/foobar"
+ # rel_path = "bar"
+ return None
+
+ return rel_path
diff --git a/WebKitTools/Scripts/webkitpy/common/system/ospath_unittest.py b/WebKitTools/Scripts/webkitpy/common/system/ospath_unittest.py
new file mode 100644
index 0000000..0493c68
--- /dev/null
+++ b/WebKitTools/Scripts/webkitpy/common/system/ospath_unittest.py
@@ -0,0 +1,62 @@
+# Copyright (C) 2010 Chris Jerdonek (cjerdonek@webkit.org)
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+# 1. Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# 2. Redistributions in binary form must reproduce the above copyright
+# notice, this list of conditions and the following disclaimer in the
+# documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS'' AND
+# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+# DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS BE LIABLE FOR
+# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+"""Unit tests for ospath.py."""
+
+import os
+import unittest
+
+from webkitpy.common.system.ospath import relpath
+
+
+# Make sure the tests in this class are platform independent.
+class RelPathTest(unittest.TestCase):
+
+ """Tests relpath()."""
+
+ os_path_abspath = lambda self, path: path
+
+ def _rel_path(self, path, abs_start_path):
+ return relpath(path, abs_start_path, self.os_path_abspath)
+
+ def test_same_path(self):
+ rel_path = self._rel_path("WebKit", "WebKit")
+ self.assertEquals(rel_path, "")
+
+ def test_long_rel_path(self):
+ start_path = "WebKit"
+ expected_rel_path = os.path.join("test", "Foo.txt")
+ path = os.path.join(start_path, expected_rel_path)
+
+ rel_path = self._rel_path(path, start_path)
+ self.assertEquals(expected_rel_path, rel_path)
+
+ def test_none_rel_path(self):
+ """Test _rel_path() with None return value."""
+ start_path = "WebKit"
+ path = os.path.join("other_dir", "foo.txt")
+
+ rel_path = self._rel_path(path, start_path)
+ self.assertTrue(rel_path is None)
+
+ rel_path = self._rel_path("WebKitTools", "WebKit")
+ self.assertTrue(rel_path is None)
diff --git a/WebKitTools/Scripts/webkitpy/common/system/outputcapture.py b/WebKitTools/Scripts/webkitpy/common/system/outputcapture.py
new file mode 100644
index 0000000..592a669
--- /dev/null
+++ b/WebKitTools/Scripts/webkitpy/common/system/outputcapture.py
@@ -0,0 +1,62 @@
+# Copyright (c) 2009, Google Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# * Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following disclaimer
+# in the documentation and/or other materials provided with the
+# distribution.
+# * Neither the name of Google Inc. nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+#
+# Class for unittest support. Used for capturing stderr/stdout.
+
+import sys
+from StringIO import StringIO
+
+class OutputCapture(object):
+ def __init__(self):
+ self.saved_outputs = dict()
+
+ def _capture_output_with_name(self, output_name):
+ self.saved_outputs[output_name] = getattr(sys, output_name)
+ setattr(sys, output_name, StringIO())
+
+ def _restore_output_with_name(self, output_name):
+ captured_output = getattr(sys, output_name).getvalue()
+ setattr(sys, output_name, self.saved_outputs[output_name])
+ del self.saved_outputs[output_name]
+ return captured_output
+
+ def capture_output(self):
+ self._capture_output_with_name("stdout")
+ self._capture_output_with_name("stderr")
+
+ def restore_output(self):
+ return (self._restore_output_with_name("stdout"), self._restore_output_with_name("stderr"))
+
+ def assert_outputs(self, testcase, function, args=[], kwargs={}, expected_stdout="", expected_stderr=""):
+ self.capture_output()
+ return_value = function(*args, **kwargs)
+ (stdout_string, stderr_string) = self.restore_output()
+ testcase.assertEqual(stdout_string, expected_stdout)
+ testcase.assertEqual(stderr_string, expected_stderr)
+ # This is a little strange, but I don't know where else to return this information.
+ return return_value
diff --git a/WebKitTools/Scripts/webkitpy/common/system/user.py b/WebKitTools/Scripts/webkitpy/common/system/user.py
new file mode 100644
index 0000000..076f965
--- /dev/null
+++ b/WebKitTools/Scripts/webkitpy/common/system/user.py
@@ -0,0 +1,82 @@
+# Copyright (c) 2009, Google Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# * Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following disclaimer
+# in the documentation and/or other materials provided with the
+# distribution.
+# * Neither the name of Google Inc. nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import os
+import shlex
+import subprocess
+import webbrowser
+
+try:
+ import readline
+except ImportError:
+ print "Unable to import readline. If you're using MacPorts, try running:"
+ print " sudo port install py25-readline"
+ exit(1)
+
+
+class User(object):
+ # FIXME: These are @classmethods because scm.py and bugzilla.py don't have a Tool object (thus no User instance).
+ @classmethod
+ def prompt(cls, message, repeat=1, raw_input=raw_input):
+ response = None
+ while (repeat and not response):
+ repeat -= 1
+ response = raw_input(message)
+ return response
+
+ @classmethod
+ def prompt_with_list(cls, list_title, list_items):
+ print list_title
+ i = 0
+ for item in list_items:
+ i += 1
+ print "%2d. %s" % (i, item)
+ result = int(cls.prompt("Enter a number: ")) - 1
+ return list_items[result]
+
+ def edit(self, files):
+ editor = os.environ.get("EDITOR") or "vi"
+ args = shlex.split(editor)
+ subprocess.call(args + files)
+
+ def page(self, message):
+ pager = os.environ.get("PAGER") or "less"
+ try:
+ child_process = subprocess.Popen([pager], stdin=subprocess.PIPE)
+ child_process.communicate(input=message)
+ except IOError, e:
+ pass
+
+ def confirm(self, message=None):
+ if not message:
+ message = "Continue?"
+ response = raw_input("%s [Y/n]: " % message)
+ return not response or response.lower() == "y"
+
+ def open_url(self, url):
+ webbrowser.open(url)
diff --git a/WebKitTools/Scripts/webkitpy/common/system/user_unittest.py b/WebKitTools/Scripts/webkitpy/common/system/user_unittest.py
new file mode 100644
index 0000000..dadead3
--- /dev/null
+++ b/WebKitTools/Scripts/webkitpy/common/system/user_unittest.py
@@ -0,0 +1,54 @@
+# Copyright (C) 2010 Research in Motion Ltd. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# * Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following disclaimer
+# in the documentation and/or other materials provided with the
+# distribution.
+# * Neither the name of Research in Motion Ltd. nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import unittest
+
+from webkitpy.common.system.user import User
+
+class UserTest(unittest.TestCase):
+
+ example_user_response = "example user response"
+
+ def test_prompt_repeat(self):
+ self.repeatsRemaining = 2
+ def mock_raw_input(message):
+ self.repeatsRemaining -= 1
+ if not self.repeatsRemaining:
+ return UserTest.example_user_response
+ return None
+ self.assertEqual(User.prompt("input", repeat=self.repeatsRemaining, raw_input=mock_raw_input), UserTest.example_user_response)
+
+ def test_prompt_when_exceeded_repeats(self):
+ self.repeatsRemaining = 2
+ def mock_raw_input(message):
+ self.repeatsRemaining -= 1
+ return None
+ self.assertEqual(User.prompt("input", repeat=self.repeatsRemaining, raw_input=mock_raw_input), None)
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/WebKitTools/Scripts/webkitpy/common/thread/__init__.py b/WebKitTools/Scripts/webkitpy/common/thread/__init__.py
new file mode 100644
index 0000000..ef65bee
--- /dev/null
+++ b/WebKitTools/Scripts/webkitpy/common/thread/__init__.py
@@ -0,0 +1 @@
+# Required for Python to search this directory for module files
diff --git a/WebKitTools/Scripts/webkitpy/common/thread/messagepump.py b/WebKitTools/Scripts/webkitpy/common/thread/messagepump.py
new file mode 100644
index 0000000..0e39285
--- /dev/null
+++ b/WebKitTools/Scripts/webkitpy/common/thread/messagepump.py
@@ -0,0 +1,59 @@
+# Copyright (c) 2010 Google Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# * Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following disclaimer
+# in the documentation and/or other materials provided with the
+# distribution.
+# * Neither the name of Google Inc. nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+
+class MessagePumpDelegate(object):
+ def schedule(self, interval, callback):
+ raise NotImplementedError, "subclasses must implement"
+
+ def message_available(self, message):
+ raise NotImplementedError, "subclasses must implement"
+
+ def final_message_delivered(self):
+ raise NotImplementedError, "subclasses must implement"
+
+
+class MessagePump(object):
+ interval = 10 # seconds
+
+ def __init__(self, delegate, message_queue):
+ self._delegate = delegate
+ self._message_queue = message_queue
+ self._schedule()
+
+ def _schedule(self):
+ self._delegate.schedule(self.interval, self._callback)
+
+ def _callback(self):
+ (messages, is_running) = self._message_queue.take_all()
+ for message in messages:
+ self._delegate.message_available(message)
+ if not is_running:
+ self._delegate.final_message_delivered()
+ return
+ self._schedule()
diff --git a/WebKitTools/Scripts/webkitpy/common/thread/messagepump_unittest.py b/WebKitTools/Scripts/webkitpy/common/thread/messagepump_unittest.py
new file mode 100644
index 0000000..f731db2
--- /dev/null
+++ b/WebKitTools/Scripts/webkitpy/common/thread/messagepump_unittest.py
@@ -0,0 +1,83 @@
+# Copyright (C) 2010 Google Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# * Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following disclaimer
+# in the documentation and/or other materials provided with the
+# distribution.
+# * Neither the name of Google Inc. nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import unittest
+
+from webkitpy.common.thread.messagepump import MessagePump, MessagePumpDelegate
+from webkitpy.common.thread.threadedmessagequeue import ThreadedMessageQueue
+
+
+class TestDelegate(MessagePumpDelegate):
+ def __init__(self):
+ self.log = []
+
+ def schedule(self, interval, callback):
+ self.callback = callback
+ self.log.append("schedule")
+
+ def message_available(self, message):
+ self.log.append("message_available: %s" % message)
+
+ def final_message_delivered(self):
+ self.log.append("final_message_delivered")
+
+
+class MessagePumpTest(unittest.TestCase):
+
+ def test_basic(self):
+ queue = ThreadedMessageQueue()
+ delegate = TestDelegate()
+ pump = MessagePump(delegate, queue)
+ self.assertEqual(delegate.log, [
+ 'schedule'
+ ])
+ delegate.callback()
+ queue.post("Hello")
+ queue.post("There")
+ delegate.callback()
+ self.assertEqual(delegate.log, [
+ 'schedule',
+ 'schedule',
+ 'message_available: Hello',
+ 'message_available: There',
+ 'schedule'
+ ])
+ queue.post("More")
+ queue.post("Messages")
+ queue.stop()
+ delegate.callback()
+ self.assertEqual(delegate.log, [
+ 'schedule',
+ 'schedule',
+ 'message_available: Hello',
+ 'message_available: There',
+ 'schedule',
+ 'message_available: More',
+ 'message_available: Messages',
+ 'final_message_delivered'
+ ])
diff --git a/WebKitTools/Scripts/webkitpy/common/thread/threadedmessagequeue.py b/WebKitTools/Scripts/webkitpy/common/thread/threadedmessagequeue.py
new file mode 100644
index 0000000..6cb6f8c
--- /dev/null
+++ b/WebKitTools/Scripts/webkitpy/common/thread/threadedmessagequeue.py
@@ -0,0 +1,55 @@
+# Copyright (c) 2010 Google Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# * Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following disclaimer
+# in the documentation and/or other materials provided with the
+# distribution.
+# * Neither the name of Google Inc. nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import threading
+
+
+class ThreadedMessageQueue(object):
+ def __init__(self):
+ self._messages = []
+ self._is_running = True
+ self._lock = threading.Lock()
+
+ def post(self, message):
+ self._lock.acquire()
+ self._messages.append(message)
+ self._lock.release()
+
+ def stop(self):
+ self._lock.acquire()
+ self._is_running = False
+ self._lock.release()
+
+ def take_all(self):
+ self._lock.acquire()
+ messages = self._messages
+ is_running = self._is_running
+ self._messages = []
+ self._lock.release()
+ return (messages, is_running)
+
diff --git a/WebKitTools/Scripts/webkitpy/common/thread/threadedmessagequeue_unittest.py b/WebKitTools/Scripts/webkitpy/common/thread/threadedmessagequeue_unittest.py
new file mode 100644
index 0000000..cb67c1e
--- /dev/null
+++ b/WebKitTools/Scripts/webkitpy/common/thread/threadedmessagequeue_unittest.py
@@ -0,0 +1,53 @@
+# Copyright (C) 2010 Google Inc. All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+# * Redistributions in binary form must reproduce the above
+# copyright notice, this list of conditions and the following disclaimer
+# in the documentation and/or other materials provided with the
+# distribution.
+# * Neither the name of Google Inc. nor the names of its
+# contributors may be used to endorse or promote products derived from
+# this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import unittest
+
+from webkitpy.common.thread.threadedmessagequeue import ThreadedMessageQueue
+
+class ThreadedMessageQueueTest(unittest.TestCase):
+
+ def test_basic(self):
+ queue = ThreadedMessageQueue()
+ queue.post("Hello")
+ queue.post("There")
+ (messages, is_running) = queue.take_all()
+ self.assertEqual(messages, ["Hello", "There"])
+ self.assertTrue(is_running)
+ (messages, is_running) = queue.take_all()
+ self.assertEqual(messages, [])
+ self.assertTrue(is_running)
+ queue.post("More")
+ queue.stop()
+ queue.post("Messages")
+ (messages, is_running) = queue.take_all()
+ self.assertEqual(messages, ["More", "Messages"])
+ self.assertFalse(is_running)
+ (messages, is_running) = queue.take_all()
+ self.assertEqual(messages, [])
+ self.assertFalse(is_running)