| #!/usr/bin/python |
| |
| # Copyright (c) 2010 The Chromium OS Authors. All rights reserved. |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| """Helper script for printing differences between tags.""" |
| |
| import cgi |
| from datetime import datetime |
| import operator |
| import optparse |
| import os |
| import re |
| import sys |
| |
| sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../lib')) |
| from cros_build_lib import RunCommand |
| |
| |
| # TODO(dianders): |
| # We use GData to access the tracker on code.google.com. Eventually, we |
| # want to create an ebuild and add the ebuild to hard-host-depends |
| # For now, we'll just include instructions for installing it. |
| INSTRS_FOR_GDATA = """ |
| To access the tracker you need the GData library. To install in your home dir: |
| |
| GDATA_INSTALL_DIR=~/gdatalib |
| mkdir -p "$GDATA_INSTALL_DIR" |
| |
| TMP_DIR=`mktemp -d` |
| pushd $TMP_DIR |
| wget http://gdata-python-client.googlecode.com/files/gdata-2.0.12.zip |
| unzip gdata-2.0.12.zip |
| cd gdata-2.0.12/ |
| python setup.py install --home="$GDATA_INSTALL_DIR" |
| popd |
| |
| export PYTHONPATH="$GDATA_INSTALL_DIR/lib/python:$PYTHONPATH" |
| |
| You should add the PYTHONPATH line to your .bashrc file (or equivalent).""" |
| |
| |
| DEFAULT_TRACKER = 'chromium-os' |
| |
| |
| def _GrabOutput(cmd): |
| """Returns output from specified command.""" |
| return RunCommand(cmd, shell=True, print_cmd=False, |
| redirect_stdout=True).output |
| |
| |
| def _GrabTags(): |
| """Returns list of tags from current git repository.""" |
| # TODO(dianders): replace this with the python equivalent. |
| cmd = ("git for-each-ref refs/tags | awk '{print $3}' | " |
| "sed 's,refs/tags/,,g' | sort -t. -k3,3rn -k4,4rn") |
| return _GrabOutput(cmd).split() |
| |
| |
| def _GrabDirs(): |
| """Returns list of directories managed by repo.""" |
| return _GrabOutput('repo forall -c "pwd"').split() |
| |
| |
| class Issue(object): |
| """Class for holding info about issues (aka bugs).""" |
| |
| def __init__(self, project_name, issue_id, tracker_acc): |
| """Constructor for Issue object. |
| |
| Args: |
| project_name: The tracker project to query. |
| issue_id: The ID of the issue to query |
| tracker_acc: A TrackerAccess object, or None. |
| """ |
| self.project_name = project_name |
| self.issue_id = issue_id |
| self.milestone = '' |
| self.priority = '' |
| |
| if tracker_acc is not None: |
| keyed_labels = tracker_acc.GetKeyedLabels(project_name, issue_id) |
| if 'Mstone' in keyed_labels: |
| self.milestone = keyed_labels['Mstone'] |
| if 'Pri' in keyed_labels: |
| self.priority = keyed_labels['Pri'] |
| |
| def GetUrl(self): |
| """Returns the URL to access the issue.""" |
| bug_url_fmt = 'http://code.google.com/p/%s/issues/detail?id=%s' |
| |
| # Get bug URL. We use short URLs to make the URLs a bit more readable. |
| if self.project_name == 'chromium-os': |
| bug_url = 'http://crosbug.com/%s' % self.issue_id |
| elif self.project_name == 'chrome-os-partner': |
| bug_url = 'http://crosbug.com/p/%s' % self.issue_id |
| else: |
| bug_url = bug_url_fmt % (self.project_name, self.issue_id) |
| |
| return bug_url |
| |
| def __str__(self): |
| """Provides a string representation of the issue. |
| |
| Returns: |
| A string that looks something like: |
| |
| project:id (milestone, priority) |
| """ |
| if self.milestone and self.priority: |
| info_str = ' (%s, P%s)' % (self.milestone, self.priority) |
| elif self.milestone: |
| info_str = ' (%s)' % self.milestone |
| elif self.priority: |
| info_str = ' (P%s)' % self.priority |
| else: |
| info_str = '' |
| |
| return '%s:%s%s' % (self.project_name, self.issue_id, info_str) |
| |
| def __cmp__(self, other): |
| """Compare two Issue objects.""" |
| return cmp((self.project_name.lower(), self.issue_id), |
| (other.project_name.lower(), other.issue_id)) |
| |
| |
| class Commit(object): |
| """Class for tracking git commits.""" |
| |
| def __init__(self, commit, projectname, commit_email, commit_date, subject, |
| body, tracker_acc): |
| """Create commit logs. |
| |
| Args: |
| commit: The commit hash (sha) from git. |
| projectname: The project name, from: |
| git config --get remote.cros.projectname |
| commit_email: The email address associated with the commit (%ce in git |
| log) |
| commit_date: The date of the commit, like "Mon Nov 1 17:34:14 2010 -0500" |
| (%cd in git log)) |
| subject: The subject of the commit (%s in git log) |
| body: The body of the commit (%b in git log) |
| tracker_acc: A tracker_access.TrackerAccess object. |
| """ |
| self.commit = commit |
| self.projectname = projectname |
| self.commit_email = commit_email |
| fmt = '%a %b %d %H:%M:%S %Y' |
| self.commit_date = datetime.strptime(commit_date, fmt) |
| self.subject = subject |
| self.body = body |
| self._tracker_acc = tracker_acc |
| self._issues = self._GetIssues() |
| |
| def _GetIssues(self): |
| """Get bug info from commit logs and issue tracker. |
| |
| This should be called as the last step of __init__, since it |
| assumes that our member variables are already setup. |
| |
| Returns: |
| A list of Issue objects, each of which holds info about a bug. |
| """ |
| # NOTE: most of this code is copied from bugdroid: |
| # <http://src.chromium.org/viewvc/chrome/trunk/tools/bugdroid/bugdroid.py?revision=59229&view=markup> |
| |
| # Get a list of bugs. Handle lots of possibilities: |
| # - Multiple "BUG=" lines, with varying amounts of whitespace. |
| # - For each BUG= line, bugs can be split by commas _or_ by whitespace (!) |
| entries = [] |
| for line in self.body.split('\n'): |
| match = re.match(r'^ *BUG *=(.*)', line) |
| if match: |
| for i in match.group(1).split(','): |
| entries.extend(filter(None, [x.strip() for x in i.split()])) |
| |
| # Try to parse the bugs. Handle lots of different formats: |
| # - The whole URL, from which we parse the project and bug. |
| # - A simple string that looks like "project:bug" |
| # - A string that looks like "bug", which will always refer to the previous |
| # tracker referenced (defaulting to the default tracker). |
| # |
| # We will create an "Issue" object for each bug. |
| issues = [] |
| last_tracker = DEFAULT_TRACKER |
| regex = (r'http://code.google.com/p/(\S+)/issues/detail\?id=([0-9]+)' |
| r'|(\S+):([0-9]+)|(\b[0-9]+\b)') |
| |
| for new_item in entries: |
| bug_numbers = re.findall(regex, new_item) |
| for bug_tuple in bug_numbers: |
| if bug_tuple[0] and bug_tuple[1]: |
| issues.append(Issue(bug_tuple[0], bug_tuple[1], self._tracker_acc)) |
| last_tracker = bug_tuple[0] |
| elif bug_tuple[2] and bug_tuple[3]: |
| issues.append(Issue(bug_tuple[2], bug_tuple[3], self._tracker_acc)) |
| last_tracker = bug_tuple[2] |
| elif bug_tuple[4]: |
| issues.append(Issue(last_tracker, bug_tuple[4], self._tracker_acc)) |
| |
| # Sort the issues and return... |
| issues.sort() |
| return issues |
| |
| def AsHTMLTableRow(self): |
| """Returns HTML for this change, for printing as part of a table. |
| |
| Columns: Project, Date, Commit, Committer, Bugs, Subject. |
| |
| Returns: |
| A string usable as an HTML table row, like: |
| |
| <tr><td>Blah</td><td>Blah blah</td></tr> |
| """ |
| |
| bugs = [] |
| link_fmt = '<a href="%s">%s</a>' |
| for issue in self._issues: |
| bugs.append(link_fmt % (issue.GetUrl(), str(issue))) |
| |
| url_fmt = 'http://chromiumos-git/git/?p=%s.git;a=commitdiff;h=%s' |
| url = url_fmt % (self.projectname, self.commit) |
| commit_desc = link_fmt % (url, self.commit[:8]) |
| bug_str = '<br>'.join(bugs) |
| if not bug_str: |
| if (self.projectname == 'kernel-next' or |
| self.commit_email == 'chrome-bot@chromium.org'): |
| bug_str = 'not needed' |
| else: |
| bug_str = '<font color="red">none</font>' |
| |
| cols = [ |
| cgi.escape(self.projectname), |
| str(self.commit_date), |
| commit_desc, |
| cgi.escape(self.commit_email), |
| bug_str, |
| cgi.escape(self.subject[:100]), |
| ] |
| return '<tr><td>%s</td></tr>' % ('</td><td>'.join(cols)) |
| |
| def __cmp__(self, other): |
| """Compare two Commit objects first by project name, then by date.""" |
| return (cmp(self.projectname, other.projectname) or |
| cmp(self.commit_date, other.commit_date)) |
| |
| |
| def _GrabChanges(path, tag1, tag2, tracker_acc): |
| """Return list of commits to path between tag1 and tag2. |
| |
| Args: |
| path: One of the directories managed by repo. |
| tag1: The first of the two tags to pass to git log. |
| tag2: The second of the two tags to pass to git log. |
| tracker_acc: A tracker_access.TrackerAccess object. |
| |
| Returns: |
| A list of "Commit" objects. |
| """ |
| |
| cmd = 'cd %s && git config --get remote.cros.projectname' % path |
| projectname = _GrabOutput(cmd).strip() |
| log_fmt = '%x00%H\t%ce\t%cd\t%s\t%b' |
| cmd_fmt = 'cd %s && git log --format="%s" --date=local "%s..%s"' |
| cmd = cmd_fmt % (path, log_fmt, tag1, tag2) |
| output = _GrabOutput(cmd) |
| commits = [] |
| for log_data in output.split('\0')[1:]: |
| commit, commit_email, commit_date, subject, body = log_data.split('\t', 4) |
| change = Commit(commit, projectname, commit_email, commit_date, subject, |
| body, tracker_acc) |
| commits.append(change) |
| return commits |
| |
| |
| def _ParseArgs(): |
| """Parse command-line arguments. |
| |
| Returns: |
| An optparse.OptionParser object. |
| """ |
| parser = optparse.OptionParser() |
| parser.add_option( |
| '--sort-by-date', dest='sort_by_date', default=False, |
| action='store_true', help='Sort commits by date.') |
| parser.add_option( |
| '--tracker-user', dest='tracker_user', default=None, |
| help='Specify a username to login to code.google.com.') |
| parser.add_option( |
| '--tracker-pass', dest='tracker_pass', default=None, |
| help='Specify a password to go w/ user.') |
| parser.add_option( |
| '--tracker-passfile', dest='tracker_passfile', default=None, |
| help='Specify a file containing a password to go w/ user.') |
| return parser.parse_args() |
| |
| |
| def main(): |
| tags = _GrabTags() |
| tag1 = None |
| options, args = _ParseArgs() |
| if len(args) == 2: |
| tag1, tag2 = args |
| elif len(args) == 1: |
| tag2, = args |
| if tag2 in tags: |
| tag2_index = tags.index(tag2) |
| if tag2_index == len(tags) - 1: |
| print >>sys.stderr, 'No previous tag for %s' % tag2 |
| sys.exit(1) |
| tag1 = tags[tag2_index + 1] |
| else: |
| print >>sys.stderr, 'Unrecognized tag: %s' % tag2 |
| sys.exit(1) |
| else: |
| print >>sys.stderr, 'Usage: %s [tag1] tag2' % sys.argv[0] |
| print >>sys.stderr, 'If only one tag is specified, we view the differences' |
| print >>sys.stderr, 'between that tag and the previous tag. You can also' |
| print >>sys.stderr, 'specify cros/master to show differences with' |
| print >>sys.stderr, 'tip-of-tree.' |
| print >>sys.stderr, 'E.g. %s %s cros/master' % (sys.argv[0], tags[0]) |
| sys.exit(1) |
| |
| if options.tracker_user is not None: |
| # TODO(dianders): Once we install GData automatically, move the import |
| # to the top of the file where it belongs. It's only here to allow |
| # people to run the script without GData. |
| try: |
| import tracker_access |
| except ImportError: |
| print >>sys.stderr, INSTRS_FOR_GDATA |
| sys.exit(1) |
| if options.tracker_passfile is not None: |
| options.tracker_pass = open(options.tracker_passfile, 'r').read().strip() |
| tracker_acc = tracker_access.TrackerAccess(options.tracker_user, |
| options.tracker_pass) |
| else: |
| tracker_acc = None |
| |
| print >>sys.stderr, 'Finding differences between %s and %s' % (tag1, tag2) |
| paths = _GrabDirs() |
| changes = [] |
| for path in paths: |
| changes.extend(_GrabChanges(path, tag1, tag2, tracker_acc)) |
| |
| title = 'Changelog for %s to %s' % (tag1, tag2) |
| print '<html>' |
| print '<head><title>%s</title></head>' % title |
| print '<h1>%s</h1>' % title |
| cols = ['Project', 'Date', 'Commit', 'Committer', 'Bugs', 'Subject'] |
| print '<table border="1" cellpadding="4">' |
| print '<tr><th>%s</th>' % ('</th><th>'.join(cols)) |
| if options.sort_by_date: |
| changes.sort(key=operator.attrgetter('commit_date')) |
| else: |
| changes.sort() |
| for change in changes: |
| print change.AsHTMLTableRow() |
| print '</table>' |
| print '</html>' |
| |
| |
| if __name__ == '__main__': |
| main() |