2011-01-28 Adam Barth <abarth@webkit.org>
[WebKit.git] / Tools / Scripts / webkitpy / common / checkout / api.py
1 # Copyright (c) 2010 Google Inc. All rights reserved.
2
3 # Redistribution and use in source and binary forms, with or without
4 # modification, are permitted provided that the following conditions are
5 # met:
6
7 #     * Redistributions of source code must retain the above copyright
8 # notice, this list of conditions and the following disclaimer.
9 #     * Redistributions in binary form must reproduce the above
10 # copyright notice, this list of conditions and the following disclaimer
11 # in the documentation and/or other materials provided with the
12 # distribution.
13 #     * Neither the name of Google Inc. nor the names of its
14 # contributors may be used to endorse or promote products derived from
15 # this software without specific prior written permission.
16
17 # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
18 # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
19 # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
20 # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
21 # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
22 # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
23 # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
24 # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
25 # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
26 # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
27 # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
28
29 import os
30 import StringIO
31
32 from webkitpy.common.config import urls
33 from webkitpy.common.checkout.changelog import ChangeLog
34 from webkitpy.common.checkout.commitinfo import CommitInfo
35 from webkitpy.common.checkout.scm import CommitMessage
36 from webkitpy.common.checkout.deps import DEPS
37 from webkitpy.common.memoized import memoized
38 from webkitpy.common.net.bugzilla import parse_bug_id
39 from webkitpy.common.system.executive import Executive, run_command, ScriptError
40 from webkitpy.common.system.deprecated_logging import log
41
42
43 # This class represents the WebKit-specific parts of the checkout (like ChangeLogs).
44 # FIXME: Move a bunch of ChangeLog-specific processing from SCM to this object.
45 # NOTE: All paths returned from this class should be absolute.
46 class Checkout(object):
47     def __init__(self, scm):
48         self._scm = scm
49
50     def is_path_to_changelog(self, path):
51         return os.path.basename(path) == "ChangeLog"
52
53     def _latest_entry_for_changelog_at_revision(self, changelog_path, revision):
54         changelog_contents = self._scm.contents_at_revision(changelog_path, revision)
55         # contents_at_revision returns a byte array (str()), but we know
56         # that ChangeLog files are utf-8.  parse_latest_entry_from_file
57         # expects a file-like object which vends unicode(), so we decode here.
58         # Old revisions of Sources/WebKit/wx/ChangeLog have some invalid utf8 characters.
59         changelog_file = StringIO.StringIO(changelog_contents.decode("utf-8", "ignore"))
60         return ChangeLog.parse_latest_entry_from_file(changelog_file)
61
62     def changelog_entries_for_revision(self, revision):
63         changed_files = self._scm.changed_files_for_revision(revision)
64         # FIXME: This gets confused if ChangeLog files are moved, as
65         # deletes are still "changed files" per changed_files_for_revision.
66         # FIXME: For now we hack around this by caching any exceptions
67         # which result from having deleted files included the changed_files list.
68         changelog_entries = []
69         for path in changed_files:
70             if not self.is_path_to_changelog(path):
71                 continue
72             try:
73                 changelog_entries.append(self._latest_entry_for_changelog_at_revision(path, revision))
74             except ScriptError:
75                 pass
76         return changelog_entries
77
78     @memoized
79     def commit_info_for_revision(self, revision):
80         committer_email = self._scm.committer_email_for_revision(revision)
81         changelog_entries = self.changelog_entries_for_revision(revision)
82         # Assume for now that the first entry has everything we need:
83         # FIXME: This will throw an exception if there were no ChangeLogs.
84         if not len(changelog_entries):
85             return None
86         changelog_entry = changelog_entries[0]
87         changelog_data = {
88             "bug_id": parse_bug_id(changelog_entry.contents()),
89             "author_name": changelog_entry.author_name(),
90             "author_email": changelog_entry.author_email(),
91             "author": changelog_entry.author(),
92             "reviewer_text": changelog_entry.reviewer_text(),
93             "reviewer": changelog_entry.reviewer(),
94         }
95         # We could pass the changelog_entry instead of a dictionary here, but that makes
96         # mocking slightly more involved, and would make aggregating data from multiple
97         # entries more difficult to wire in if we need to do that in the future.
98         return CommitInfo(revision, committer_email, changelog_data)
99
100     def bug_id_for_revision(self, revision):
101         return self.commit_info_for_revision(revision).bug_id()
102
103     def _modified_files_matching_predicate(self, git_commit, predicate, changed_files=None):
104         # SCM returns paths relative to scm.checkout_root
105         # Callers (especially those using the ChangeLog class) may
106         # expect absolute paths, so this method returns absolute paths.
107         if not changed_files:
108             changed_files = self._scm.changed_files(git_commit)
109         absolute_paths = [os.path.join(self._scm.checkout_root, path) for path in changed_files]
110         return [path for path in absolute_paths if predicate(path)]
111
112     def modified_changelogs(self, git_commit, changed_files=None):
113         return self._modified_files_matching_predicate(git_commit, self.is_path_to_changelog, changed_files=changed_files)
114
115     def modified_non_changelogs(self, git_commit, changed_files=None):
116         return self._modified_files_matching_predicate(git_commit, lambda path: not self.is_path_to_changelog(path), changed_files=changed_files)
117
118     def commit_message_for_this_commit(self, git_commit, changed_files=None):
119         changelog_paths = self.modified_changelogs(git_commit, changed_files)
120         if not len(changelog_paths):
121             raise ScriptError(message="Found no modified ChangeLogs, cannot create a commit message.\n"
122                               "All changes require a ChangeLog.  See:\n %s" % urls.contribution_guidelines)
123
124         changelog_messages = []
125         for changelog_path in changelog_paths:
126             log("Parsing ChangeLog: %s" % changelog_path)
127             changelog_entry = ChangeLog(changelog_path).latest_entry()
128             if not changelog_entry:
129                 raise ScriptError(message="Failed to parse ChangeLog: %s" % os.path.abspath(changelog_path))
130             changelog_messages.append(changelog_entry.contents())
131
132         # FIXME: We should sort and label the ChangeLog messages like commit-log-editor does.
133         return CommitMessage("".join(changelog_messages).splitlines())
134
135     def recent_commit_infos_for_files(self, paths):
136         revisions = set(sum(map(self._scm.revisions_changing_file, paths), []))
137         return set(map(self.commit_info_for_revision, revisions))
138
139     def suggested_reviewers(self, git_commit, changed_files=None):
140         changed_files = self.modified_non_changelogs(git_commit, changed_files)
141         commit_infos = self.recent_commit_infos_for_files(changed_files)
142         reviewers = [commit_info.reviewer() for commit_info in commit_infos if commit_info.reviewer()]
143         reviewers.extend([commit_info.author() for commit_info in commit_infos if commit_info.author() and commit_info.author().can_review])
144         return sorted(set(reviewers))
145
146     def bug_id_for_this_commit(self, git_commit, changed_files=None):
147         try:
148             return parse_bug_id(self.commit_message_for_this_commit(git_commit, changed_files).message())
149         except ScriptError, e:
150             pass # We might not have ChangeLogs.
151
152     def chromium_deps(self):
153         return DEPS(os.path.join(self._scm.checkout_root, "Source", "WebKit", "chromium", "DEPS"))
154
155     def apply_patch(self, patch, force=False):
156         # It's possible that the patch was not made from the root directory.
157         # We should detect and handle that case.
158         # FIXME: Move _scm.script_path here once we get rid of all the dependencies.
159         args = [self._scm.script_path('svn-apply')]
160         if patch.reviewer():
161             args += ['--reviewer', patch.reviewer().full_name]
162         if force:
163             args.append('--force')
164         run_command(args, input=patch.contents())
165
166     def apply_reverse_diff(self, revision):
167         self._scm.apply_reverse_diff(revision)
168
169         # We revert the ChangeLogs because removing lines from a ChangeLog
170         # doesn't make sense.  ChangeLogs are append only.
171         changelog_paths = self.modified_changelogs(git_commit=None)
172         if len(changelog_paths):
173             self._scm.revert_files(changelog_paths)
174
175         conflicts = self._scm.conflicted_files()
176         if len(conflicts):
177             raise ScriptError(message="Failed to apply reverse diff for revision %s because of the following conflicts:\n%s" % (revision, "\n".join(conflicts)))
178
179     def apply_reverse_diffs(self, revision_list):
180         for revision in sorted(revision_list, reverse=True):
181             self.apply_reverse_diff(revision)