api.py revision 2bde8e466a4451c7319e3a072d118917957d6554
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
29import os
30import StringIO
31
32from webkitpy.common.config import urls
33from webkitpy.common.checkout.changelog import ChangeLog
34from webkitpy.common.checkout.commitinfo import CommitInfo
35from webkitpy.common.checkout.scm import CommitMessage
36from webkitpy.common.checkout.deps import DEPS
37from webkitpy.common.memoized import memoized
38from webkitpy.common.net.bugzilla import parse_bug_id_from_changelog
39from webkitpy.common.system.executive import Executive, run_command, ScriptError
40from 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.
46class 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_from_changelog(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_from_changelog(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)
182