| # 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 logging |
| import re |
| import textwrap |
| |
| from webkitcorepy import StringIO, unicode |
| |
| from webkitpy.common.config.committers import CommitterList |
| from webkitpy.common.system.filesystem import FileSystem |
| import webkitpy.common.config.urls as config_urls |
| |
| _log = logging.getLogger(__name__) |
| |
| |
| # FIXME: parse_bug_id_from_changelog should not be a free function. |
| # Parse the bug ID out of a Changelog message based on the format that is |
| # used by prepare-ChangeLog |
| def parse_bug_id_from_changelog(message): |
| if not message: |
| return None |
| match = re.search(r"^\s*" + config_urls.bug_url_short + "$", message, re.MULTILINE) |
| if match: |
| return int(match.group('bug_id')) |
| match = re.search(r"^\s*" + config_urls.bug_url_long + "$", message, re.MULTILINE) |
| if match: |
| return int(match.group('bug_id')) |
| # We weren't able to find a bug URL in the format used by prepare-ChangeLog. Fall back to the |
| # first bug URL found anywhere in the message. |
| return config_urls.parse_bug_id(message) |
| |
| |
| 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<authors>(?P<name>[^<]+?)\s+<(?P<email>[^<>]+)>.*?)$' |
| |
| # e.g. * Source/WebCore/page/EventHandler.cpp: Implement FooBarQuux. |
| touched_files_regexp = r'^\s*\*\s*(?P<file>[A-Za-z0-9_\-\./\\]+)\s*\:' |
| # e.g. (ChangeLogEntry.touched_functions): Added. |
| touched_functions_regexp = r'^\s*\((?P<function>[^)]*)\):' |
| |
| radar_id_regexp = r'^\s*(<?rdar://(problems?/)?)?(?P<radar_id>-?\d{7,})>?' |
| |
| # e.g. Reviewed by Darin Adler. |
| # (Discard everything after the first period to match more invalid lines.) |
| reviewed_by_regexp = r'^\s*((\w+\s+)+and\s+)?(Review|Rubber(\s*|-)stamp)(s|ed)?\s+([a-z]+\s+)*?by\s+(?P<reviewer>.*?)[\.,]?\s*$' |
| |
| reviewed_byless_regexp = r'^\s*((Review|Rubber(\s*|-)stamp)(s|ed)?|RS)(\s+|\s*=\s*)(?P<reviewer>([A-Z]\w+\s*)+)[\.,]?\s*$' |
| |
| reviewer_name_noise_regexp = re.compile(r""" |
| (\s+((tweaked\s+)?and\s+)?(landed|committed|okayed)\s+by.+) # "landed by", "commented by", etc... |
| |(^(Reviewed\s+)?by\s+) # extra "Reviewed by" or "by" |
| |([(<]\s*[\w_\-\.]+@[\w_\-\.]+[>)]) # email addresses |
| |([(<](https?://?bugs.)webkit.org[^>)]+[>)]) # bug url |
| |("[^"]+") # wresler names like 'Sean/Shawn/Shaun' in 'Geoffrey "Sean/Shawn/Shaun" Garen' |
| |('[^']+') # wresler names like "The Belly" in "Sam 'The Belly' Weinig" |
| |((Mr|Ms|Dr|Mrs|Prof)\.(\s+|$)) |
| """, re.IGNORECASE | re.VERBOSE) |
| |
| reviewer_name_casesensitive_noise_regexp = re.compile(r""" |
| ((\s+|^)(and\s+)?([a-z-]+\s+){5,}by\s+) # e.g. "and given a good once-over by" |
| |(\(\s*(?!(and|[A-Z])).+\)) # any parenthesis that doesn't start with "and" or a capital letter |
| |(with(\s+[a-z-]+)+) # phrases with "with no hesitation" in "Sam Weinig with no hesitation" |
| """, re.VERBOSE) |
| |
| reviewer_name_noise_needing_a_backreference_regexp = re.compile(r""" |
| (\S\S)\.(?:(\s.+|$)) # Text after the two word characters (don't match initials) and a period followed by a space. |
| """, re.IGNORECASE | re.VERBOSE) |
| |
| nobody_regexp = re.compile(r"""(\s+|^)nobody( |
| ((,|\s+-)?\s+(\w+\s+)+fix.*) # e.g. nobody, build fix... |
| |(\s*\([^)]+\).*) # NOBODY (..)... |
| |$)""", re.IGNORECASE | re.VERBOSE) |
| |
| # e.g. == Rolled over to ChangeLog-2011-02-16 == |
| rolled_over_regexp = r'^== Rolled over to ChangeLog-\d{4}-\d{2}-\d{2} ==$' |
| |
| split_names_regexp = r'\s*(?:,(?:\s+and\s+|&)?|(?:^|\s+)and\s+|&&|[/+&])\s*' |
| |
| def __init__(self, contents, committer_list=None, revision=None): |
| self._contents = contents |
| self._committer_list = committer_list or CommitterList() |
| self._revision = revision |
| self._parse_entry() |
| |
| @classmethod |
| def _parse_radar_id(cls, text): |
| if not text: |
| return None |
| match = re.search(ChangeLogEntry.radar_id_regexp, text, re.MULTILINE | re.IGNORECASE) |
| if not match: |
| return None |
| radar_id = int(match.group('radar_id')) |
| if radar_id < 0: |
| return None |
| |
| return radar_id |
| |
| @classmethod |
| def _parse_reviewer_text(cls, text): |
| match = re.search(ChangeLogEntry.reviewed_by_regexp, text, re.MULTILINE | re.IGNORECASE) |
| if not match: |
| # There are cases where people omit "by". We match it only if reviewer part looked nice |
| # in order to avoid matching random lines that start with Reviewed |
| match = re.search(ChangeLogEntry.reviewed_byless_regexp, text, re.MULTILINE | re.IGNORECASE) |
| if not match: |
| return None, None |
| |
| reviewer_text = match.group("reviewer") |
| |
| reviewer_text = ChangeLogEntry.nobody_regexp.sub('', reviewer_text) |
| reviewer_text = ChangeLogEntry.reviewer_name_noise_regexp.sub('', reviewer_text) |
| reviewer_text = ChangeLogEntry.reviewer_name_casesensitive_noise_regexp.sub('', reviewer_text) |
| reviewer_text = ChangeLogEntry.reviewer_name_noise_needing_a_backreference_regexp.sub(r'\1', reviewer_text) |
| reviewer_text = reviewer_text.replace('(', '').replace(')', '') |
| reviewer_text = re.sub(r'\s\s+|[,.]\s*$', ' ', reviewer_text).strip() |
| if not len(reviewer_text): |
| return None, None |
| |
| reviewer_list = ChangeLogEntry._split_reviewer_names(reviewer_text) |
| |
| # Get rid of "reviewers" like "even though this is just a..." in "Reviewed by Sam Weinig, even though this is just a..." |
| # and "who wrote the original code" in "Noam Rosenthal, who wrote the original code" |
| reviewer_list = [reviewer for reviewer in reviewer_list if not re.match(r'^who\s|^([a-z]+(\s+|\.|$)){6,}$', reviewer)] |
| |
| return reviewer_text, reviewer_list |
| |
| @classmethod |
| def _split_reviewer_names(cls, text): |
| return re.split(ChangeLogEntry.split_names_regexp, text) |
| |
| @classmethod |
| def _split_author_names_with_emails(cls, text): |
| regex = '>' + ChangeLogEntry.split_names_regexp |
| names = re.split(regex, text) |
| if len(names) > 1: |
| names = [name + ">" for name in names[:-1]] + [names[-1]] |
| return names |
| |
| def _fuzz_match_reviewers(self, reviewers_text_list): |
| if not reviewers_text_list: |
| return [] |
| list_of_reviewers = [self._committer_list.contributors_by_fuzzy_match(reviewer)[0] for reviewer in reviewers_text_list] |
| # Flatten lists and get rid of any reviewers with more than one candidate. |
| return [reviewers[0] for reviewers in list_of_reviewers if len(reviewers) == 1] |
| |
| @classmethod |
| def _parse_author_name_and_email(cls, author_name_and_email): |
| match = re.match(r'(?P<name>.+?)\s+<(?P<email>[^>]+)>', author_name_and_email) |
| return {'name': match.group("name"), 'email': match.group("email")} |
| |
| @classmethod |
| def _parse_author_text(cls, text): |
| if not text: |
| return [] |
| authors = cls._split_author_names_with_emails(text) |
| assert(authors and len(authors) >= 1) |
| return [cls._parse_author_name_and_email(author) for author in authors] |
| |
| @classmethod |
| def _parse_touched_functions(cls, text): |
| result = {} |
| cur_file = None |
| for line in text.splitlines(): |
| file_match = re.match(cls.touched_files_regexp, line) |
| if file_match: |
| cur_file = file_match.group("file") |
| result[cur_file] = [] |
| func_match = re.match(cls.touched_functions_regexp, line) |
| if func_match and cur_file: |
| result[cur_file].append(func_match.group("function")) |
| return result |
| |
| @classmethod |
| def _parse_bug_description(cls, text): |
| # Line 3 is the bug description in most cases. |
| lines = text.splitlines() |
| if len(lines) < 3: |
| return None |
| found_reviewed = re.search(ChangeLogEntry.reviewed_by_regexp, lines[2], re.IGNORECASE) |
| found_reviewed_byless = re.search(ChangeLogEntry.reviewed_byless_regexp, lines[2], re.IGNORECASE) |
| found_url = parse_bug_id_from_changelog(lines[2]) |
| if found_reviewed or found_reviewed_byless or found_url: |
| return None |
| return lines[2].strip() |
| |
| 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) |
| |
| self._date_line = match.group() |
| self._date = match.group("date") |
| self._bug_description = self._parse_bug_description(self._contents) |
| |
| # FIXME: group("name") does not seem to be Unicode? Probably due to self._contents not being unicode. |
| self._author_text = match.group("authors") if match else None |
| self._authors = ChangeLogEntry._parse_author_text(self._author_text) |
| |
| self._reviewer_text, self._reviewers_text_list = ChangeLogEntry._parse_reviewer_text(self._contents) |
| self._reviewers = self._fuzz_match_reviewers(self._reviewers_text_list) |
| self._author = self._committer_list.contributor_by_email(self.author_email()) or self._committer_list.contributor_by_name(self.author_name()) |
| |
| self._touched_files = re.findall(self.touched_files_regexp, self._contents, re.MULTILINE) |
| self._touched_functions = self._parse_touched_functions(self._contents) |
| |
| def date_line(self): |
| return self._date_line |
| |
| def date(self): |
| return self._date |
| |
| def author_text(self): |
| return self._author_text |
| |
| def revision(self): |
| return self._revision |
| |
| def author_name(self): |
| return self._authors[0]['name'] |
| |
| def author_email(self): |
| return self._authors[0]['email'] |
| |
| def author(self): |
| return self._author # Might be None |
| |
| def authors(self): |
| return self._authors |
| |
| # 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 |
| |
| # Might be None, might also not be a Reviewer! |
| def reviewer(self): |
| return self._reviewers[0] if len(self._reviewers) > 0 else None |
| |
| def reviewers(self): |
| return self._reviewers |
| |
| def has_valid_reviewer(self): |
| if self._reviewers_text_list: |
| for reviewer in self._reviewers_text_list: |
| reviewer = self._committer_list.reviewer_by_name(reviewer) |
| if reviewer: |
| return True |
| return bool(re.search("unreviewed", self._contents, re.IGNORECASE)) |
| |
| def contents(self): |
| return self._contents |
| |
| def bug_id(self): |
| return parse_bug_id_from_changelog(self._contents) |
| |
| def bug_description(self): |
| return self._bug_description |
| |
| def touched_files(self): |
| return self._touched_files |
| |
| # Returns a dict from file name to lists of function names. |
| def touched_functions(self): |
| return self._touched_functions |
| |
| def touched_files_text(self): |
| match = re.search(self.touched_files_regexp, self._contents, re.MULTILINE) |
| return self._contents[match.start():].lstrip("\n\r") if match else "" |
| |
| # Determine if any text has been added to the section on touched files |
| def is_touched_files_text_clean(self): |
| file_line_end = r"( (Added|Removed|(Copied|Renamed) from [A-Za-z0-9_\-./\\]+).)?$" |
| for line in self.touched_files_text().splitlines(): |
| if re.match(self.touched_files_regexp + file_line_end, line): |
| continue |
| if re.match(self.touched_functions_regexp + "$", line): |
| continue |
| return False |
| return True |
| |
| |
| # FIXME: Various methods on ChangeLog should move into ChangeLogEntry instead. |
| class ChangeLog(object): |
| |
| def __init__(self, path, filesystem=None): |
| self.path = path |
| self._filesystem = filesystem or FileSystem() |
| |
| _changelog_indent = " " * 8 |
| |
| @classmethod |
| def parse_latest_entry_from_file(cls, changelog_file): |
| try: |
| return next(cls.parse_entries_from_file(changelog_file)) |
| except StopIteration: |
| return None |
| |
| svn_blame_regexp = re.compile(r'^(\s*(?P<revision>\d+) [^ ]+)\s*(?P<line>.*?\n)') |
| |
| @classmethod |
| def _separate_revision_and_line(cls, line): |
| match = cls.svn_blame_regexp.match(line) |
| if not match: |
| return None, line |
| return int(match.group('revision')), match.group('line') |
| |
| @classmethod |
| def parse_entries_from_file(cls, changelog_file): |
| """changelog_file must be a file-like object which returns |
| unicode strings, e.g. from StringIO(unicode()) or |
| fs.open_text_file_for_reading()""" |
| date_line_regexp = re.compile(ChangeLogEntry.date_line_regexp) |
| rolled_over_regexp = re.compile(ChangeLogEntry.rolled_over_regexp) |
| |
| # The first line should be a date line. |
| revision, first_line = cls._separate_revision_and_line(changelog_file.readline()) |
| assert(isinstance(first_line, unicode)) |
| if not date_line_regexp.match(cls.svn_blame_regexp.sub('', first_line)): |
| raise StopIteration |
| |
| entry_lines = [first_line] |
| revisions_in_entry = {revision: 1} if revision != None else None |
| for line in changelog_file: |
| if revisions_in_entry: |
| revision, line = cls._separate_revision_and_line(line) |
| |
| if rolled_over_regexp.match(line): |
| break |
| |
| if date_line_regexp.match(line): |
| most_probable_revision = max(revisions_in_entry, key=revisions_in_entry.__getitem__) if revisions_in_entry else None |
| # Remove the extra newline at the end |
| yield ChangeLogEntry(''.join(entry_lines[:-1]), revision=most_probable_revision) |
| entry_lines = [] |
| revisions_in_entry = {revision: 0} |
| |
| entry_lines.append(line) |
| if revisions_in_entry: |
| revisions_in_entry[revision] = revisions_in_entry.get(revision, 0) + 1 |
| |
| most_probable_revision = max(revisions_in_entry, key=revisions_in_entry.__getitem__) if revisions_in_entry else None |
| yield ChangeLogEntry(''.join(entry_lines[:-1]), revision=most_probable_revision) |
| |
| 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 = self._filesystem.open_text_file_for_reading(self.path) |
| 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) |
| |
| def update_with_unreviewed_message(self, message): |
| first_boilerplate_line_regexp = re.compile( |
| r"%sNeed a short description \(OOPS!\)\." % self._changelog_indent) |
| removing_boilerplate = False |
| result = StringIO() |
| with self._filesystem.open_text_file_for_reading(self.path) as file: |
| for line in file: |
| if first_boilerplate_line_regexp.search(line): |
| message_lines = self._wrap_lines(message) |
| result.write(first_boilerplate_line_regexp.sub(message_lines, line)) |
| # Remove all the ChangeLog boilerplate, except the first line (date, name, e-mail). |
| removing_boilerplate = True |
| elif removing_boilerplate: |
| if re.search("^[1-9]", line): # each changelog entry is preceded by a date |
| removing_boilerplate = False |
| |
| if not removing_boilerplate: |
| result.write(line) |
| self._filesystem.write_text_file(self.path, result.getvalue()) |
| |
| def set_reviewer(self, reviewer): |
| latest_entry = self.latest_entry() |
| latest_entry_contents = latest_entry.contents() |
| reviewer_text = latest_entry.reviewer() |
| found_nobody = re.search(r"NOBODY\s*\(OOPS!\)", latest_entry_contents, re.MULTILINE) |
| found_reviewer_or_unreviewed = latest_entry.has_valid_reviewer() |
| if not found_nobody and not found_reviewer_or_unreviewed and not reviewer_text: |
| bug_url_number_of_items = len(re.findall(config_urls.bug_url_long, latest_entry_contents, re.MULTILINE)) |
| bug_url_number_of_items += len(re.findall(config_urls.bug_url_short, latest_entry_contents, re.MULTILINE)) |
| result = StringIO() |
| with self._filesystem.open_text_file_for_reading(self.path) as file: |
| for line in file: |
| found_bug_url = re.search(config_urls.bug_url_long, line) |
| if not found_bug_url: |
| found_bug_url = re.search(config_urls.bug_url_short, line) |
| result.write(line) |
| if found_bug_url: |
| if bug_url_number_of_items == 1: |
| result.write("\n Reviewed by %s.\n" % reviewer) |
| bug_url_number_of_items -= 1 |
| self._filesystem.write_text_file(self.path, result.getvalue()) |
| else: |
| data = self._filesystem.read_text_file(self.path) |
| newdata = data.replace("NOBODY (OOPS!)", reviewer) |
| self._filesystem.write_text_file(self.path, newdata) |
| |
| def set_short_description_and_bug_url(self, short_description, bug_url): |
| result = StringIO() |
| with self._filesystem.open_text_file_for_reading(self.path) as file: |
| short_description_placeholder = "Need a short description (OOPS!)." |
| bug_url_placeholder = "Need the bug URL (OOPS!)." |
| for line in file: |
| stripped = line.strip() |
| if stripped == short_description_placeholder: |
| line = self._changelog_indent + short_description + "\n" |
| if stripped == bug_url_placeholder: |
| line = self._changelog_indent + bug_url + "\n" |
| result.write(line) |
| self._filesystem.write_text_file(self.path, result.getvalue()) |
| |
| def delete_entries(self, num_entries): |
| date_line_regexp = re.compile(ChangeLogEntry.date_line_regexp) |
| rolled_over_regexp = re.compile(ChangeLogEntry.rolled_over_regexp) |
| entries = 0 |
| result = StringIO() |
| with self._filesystem.open_text_file_for_reading(self.path) as file: |
| for line in file: |
| if date_line_regexp.match(line): |
| entries += 1 |
| elif rolled_over_regexp.match(line): |
| entries = num_entries + 1 |
| if entries > num_entries: |
| result.write(line) |
| self._filesystem.write_text_file(self.path, result.getvalue()) |
| |
| def prepend_text(self, text): |
| data = self._filesystem.read_text_file(self.path) |
| self._filesystem.write_text_file(self.path, text + data) |