| OLD | NEW |
| (Empty) |
| 1 #!/usr/bin/python | |
| 2 | |
| 3 # Copyright (c) 2010 The Chromium OS Authors. All rights reserved. | |
| 4 # Use of this source code is governed by a BSD-style license that can be | |
| 5 # found in the LICENSE file. | |
| 6 | |
| 7 """Helper script for printing differences between tags.""" | |
| 8 | |
| 9 import cgi | |
| 10 from datetime import datetime | |
| 11 import operator | |
| 12 import optparse | |
| 13 import os | |
| 14 import re | |
| 15 import sys | |
| 16 | |
| 17 sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../lib')) | |
| 18 from cros_build_lib import RunCommand | |
| 19 | |
| 20 | |
| 21 # TODO(dianders): | |
| 22 # We use GData to access the tracker on code.google.com. Eventually, we | |
| 23 # want to create an ebuild and add the ebuild to hard-host-depends | |
| 24 # For now, we'll just include instructions for installing it. | |
| 25 INSTRS_FOR_GDATA = """ | |
| 26 To access the tracker you need the GData library. To install in your home dir: | |
| 27 | |
| 28 GDATA_INSTALL_DIR=~/gdatalib | |
| 29 mkdir -p "$GDATA_INSTALL_DIR" | |
| 30 | |
| 31 TMP_DIR=`mktemp -d` | |
| 32 pushd $TMP_DIR | |
| 33 wget http://gdata-python-client.googlecode.com/files/gdata-2.0.12.zip | |
| 34 unzip gdata-2.0.12.zip | |
| 35 cd gdata-2.0.12/ | |
| 36 python setup.py install --home="$GDATA_INSTALL_DIR" | |
| 37 popd | |
| 38 | |
| 39 export PYTHONPATH="$GDATA_INSTALL_DIR/lib/python:$PYTHONPATH" | |
| 40 | |
| 41 You should add the PYTHONPATH line to your .bashrc file (or equivalent).""" | |
| 42 | |
| 43 | |
| 44 DEFAULT_TRACKER = 'chromium-os' | |
| 45 | |
| 46 | |
| 47 def _GrabOutput(cmd): | |
| 48 """Returns output from specified command.""" | |
| 49 return RunCommand(cmd, shell=True, print_cmd=False, | |
| 50 redirect_stdout=True).output | |
| 51 | |
| 52 | |
| 53 def _GrabTags(): | |
| 54 """Returns list of tags from current git repository.""" | |
| 55 # TODO(dianders): replace this with the python equivalent. | |
| 56 cmd = ("git for-each-ref refs/tags | awk '{print $3}' | " | |
| 57 "sed 's,refs/tags/,,g' | sort -t. -k3,3rn -k4,4rn") | |
| 58 return _GrabOutput(cmd).split() | |
| 59 | |
| 60 | |
| 61 def _GrabDirs(): | |
| 62 """Returns list of directories managed by repo.""" | |
| 63 return _GrabOutput('repo forall -c "pwd"').split() | |
| 64 | |
| 65 | |
| 66 class Issue(object): | |
| 67 """Class for holding info about issues (aka bugs).""" | |
| 68 | |
| 69 def __init__(self, project_name, issue_id, tracker_acc): | |
| 70 """Constructor for Issue object. | |
| 71 | |
| 72 Args: | |
| 73 project_name: The tracker project to query. | |
| 74 issue_id: The ID of the issue to query | |
| 75 tracker_acc: A TrackerAccess object, or None. | |
| 76 """ | |
| 77 self.project_name = project_name | |
| 78 self.issue_id = issue_id | |
| 79 self.milestone = '' | |
| 80 self.priority = '' | |
| 81 | |
| 82 if tracker_acc is not None: | |
| 83 keyed_labels = tracker_acc.GetKeyedLabels(project_name, issue_id) | |
| 84 if 'Mstone' in keyed_labels: | |
| 85 self.milestone = keyed_labels['Mstone'] | |
| 86 if 'Pri' in keyed_labels: | |
| 87 self.priority = keyed_labels['Pri'] | |
| 88 | |
| 89 def GetUrl(self): | |
| 90 """Returns the URL to access the issue.""" | |
| 91 bug_url_fmt = 'http://code.google.com/p/%s/issues/detail?id=%s' | |
| 92 | |
| 93 # Get bug URL. We use short URLs to make the URLs a bit more readable. | |
| 94 if self.project_name == 'chromium-os': | |
| 95 bug_url = 'http://crosbug.com/%s' % self.issue_id | |
| 96 elif self.project_name == 'chrome-os-partner': | |
| 97 bug_url = 'http://crosbug.com/p/%s' % self.issue_id | |
| 98 else: | |
| 99 bug_url = bug_url_fmt % (self.project_name, self.issue_id) | |
| 100 | |
| 101 return bug_url | |
| 102 | |
| 103 def __str__(self): | |
| 104 """Provides a string representation of the issue. | |
| 105 | |
| 106 Returns: | |
| 107 A string that looks something like: | |
| 108 | |
| 109 project:id (milestone, priority) | |
| 110 """ | |
| 111 if self.milestone and self.priority: | |
| 112 info_str = ' (%s, P%s)' % (self.milestone, self.priority) | |
| 113 elif self.milestone: | |
| 114 info_str = ' (%s)' % self.milestone | |
| 115 elif self.priority: | |
| 116 info_str = ' (P%s)' % self.priority | |
| 117 else: | |
| 118 info_str = '' | |
| 119 | |
| 120 return '%s:%s%s' % (self.project_name, self.issue_id, info_str) | |
| 121 | |
| 122 def __cmp__(self, other): | |
| 123 """Compare two Issue objects.""" | |
| 124 return cmp((self.project_name.lower(), self.issue_id), | |
| 125 (other.project_name.lower(), other.issue_id)) | |
| 126 | |
| 127 | |
| 128 class Commit(object): | |
| 129 """Class for tracking git commits.""" | |
| 130 | |
| 131 def __init__(self, commit, projectname, commit_email, commit_date, subject, | |
| 132 body, tracker_acc): | |
| 133 """Create commit logs. | |
| 134 | |
| 135 Args: | |
| 136 commit: The commit hash (sha) from git. | |
| 137 projectname: The project name, from: | |
| 138 git config --get remote.cros.projectname | |
| 139 commit_email: The email address associated with the commit (%ce in git | |
| 140 log) | |
| 141 commit_date: The date of the commit, like "Mon Nov 1 17:34:14 2010 -0500" | |
| 142 (%cd in git log)) | |
| 143 subject: The subject of the commit (%s in git log) | |
| 144 body: The body of the commit (%b in git log) | |
| 145 tracker_acc: A tracker_access.TrackerAccess object. | |
| 146 """ | |
| 147 self.commit = commit | |
| 148 self.projectname = projectname | |
| 149 self.commit_email = commit_email | |
| 150 fmt = '%a %b %d %H:%M:%S %Y' | |
| 151 self.commit_date = datetime.strptime(commit_date, fmt) | |
| 152 self.subject = subject | |
| 153 self.body = body | |
| 154 self._tracker_acc = tracker_acc | |
| 155 self._issues = self._GetIssues() | |
| 156 | |
| 157 def _GetIssues(self): | |
| 158 """Get bug info from commit logs and issue tracker. | |
| 159 | |
| 160 This should be called as the last step of __init__, since it | |
| 161 assumes that our member variables are already setup. | |
| 162 | |
| 163 Returns: | |
| 164 A list of Issue objects, each of which holds info about a bug. | |
| 165 """ | |
| 166 # NOTE: most of this code is copied from bugdroid: | |
| 167 # <http://src.chromium.org/viewvc/chrome/trunk/tools/bugdroid/bugdroid.py?
revision=59229&view=markup> | |
| 168 | |
| 169 # Get a list of bugs. Handle lots of possibilities: | |
| 170 # - Multiple "BUG=" lines, with varying amounts of whitespace. | |
| 171 # - For each BUG= line, bugs can be split by commas _or_ by whitespace (!) | |
| 172 entries = [] | |
| 173 for line in self.body.split('\n'): | |
| 174 match = re.match(r'^ *BUG *=(.*)', line) | |
| 175 if match: | |
| 176 for i in match.group(1).split(','): | |
| 177 entries.extend(filter(None, [x.strip() for x in i.split()])) | |
| 178 | |
| 179 # Try to parse the bugs. Handle lots of different formats: | |
| 180 # - The whole URL, from which we parse the project and bug. | |
| 181 # - A simple string that looks like "project:bug" | |
| 182 # - A string that looks like "bug", which will always refer to the previous | |
| 183 # tracker referenced (defaulting to the default tracker). | |
| 184 # | |
| 185 # We will create an "Issue" object for each bug. | |
| 186 issues = [] | |
| 187 last_tracker = DEFAULT_TRACKER | |
| 188 regex = (r'http://code.google.com/p/(\S+)/issues/detail\?id=([0-9]+)' | |
| 189 r'|(\S+):([0-9]+)|(\b[0-9]+\b)') | |
| 190 | |
| 191 for new_item in entries: | |
| 192 bug_numbers = re.findall(regex, new_item) | |
| 193 for bug_tuple in bug_numbers: | |
| 194 if bug_tuple[0] and bug_tuple[1]: | |
| 195 issues.append(Issue(bug_tuple[0], bug_tuple[1], self._tracker_acc)) | |
| 196 last_tracker = bug_tuple[0] | |
| 197 elif bug_tuple[2] and bug_tuple[3]: | |
| 198 issues.append(Issue(bug_tuple[2], bug_tuple[3], self._tracker_acc)) | |
| 199 last_tracker = bug_tuple[2] | |
| 200 elif bug_tuple[4]: | |
| 201 issues.append(Issue(last_tracker, bug_tuple[4], self._tracker_acc)) | |
| 202 | |
| 203 # Sort the issues and return... | |
| 204 issues.sort() | |
| 205 return issues | |
| 206 | |
| 207 def AsHTMLTableRow(self): | |
| 208 """Returns HTML for this change, for printing as part of a table. | |
| 209 | |
| 210 Columns: Project, Date, Commit, Committer, Bugs, Subject. | |
| 211 | |
| 212 Returns: | |
| 213 A string usable as an HTML table row, like: | |
| 214 | |
| 215 <tr><td>Blah</td><td>Blah blah</td></tr> | |
| 216 """ | |
| 217 | |
| 218 bugs = [] | |
| 219 link_fmt = '<a href="%s">%s</a>' | |
| 220 for issue in self._issues: | |
| 221 bugs.append(link_fmt % (issue.GetUrl(), str(issue))) | |
| 222 | |
| 223 url_fmt = 'http://chromiumos-git/git/?p=%s.git;a=commitdiff;h=%s' | |
| 224 url = url_fmt % (self.projectname, self.commit) | |
| 225 commit_desc = link_fmt % (url, self.commit[:8]) | |
| 226 bug_str = '<br>'.join(bugs) | |
| 227 if not bug_str: | |
| 228 if (self.projectname == 'kernel-next' or | |
| 229 self.commit_email == 'chrome-bot@chromium.org'): | |
| 230 bug_str = 'not needed' | |
| 231 else: | |
| 232 bug_str = '<font color="red">none</font>' | |
| 233 | |
| 234 cols = [ | |
| 235 cgi.escape(self.projectname), | |
| 236 str(self.commit_date), | |
| 237 commit_desc, | |
| 238 cgi.escape(self.commit_email), | |
| 239 bug_str, | |
| 240 cgi.escape(self.subject[:100]), | |
| 241 ] | |
| 242 return '<tr><td>%s</td></tr>' % ('</td><td>'.join(cols)) | |
| 243 | |
| 244 def __cmp__(self, other): | |
| 245 """Compare two Commit objects first by project name, then by date.""" | |
| 246 return (cmp(self.projectname, other.projectname) or | |
| 247 cmp(self.commit_date, other.commit_date)) | |
| 248 | |
| 249 | |
| 250 def _GrabChanges(path, tag1, tag2, tracker_acc): | |
| 251 """Return list of commits to path between tag1 and tag2. | |
| 252 | |
| 253 Args: | |
| 254 path: One of the directories managed by repo. | |
| 255 tag1: The first of the two tags to pass to git log. | |
| 256 tag2: The second of the two tags to pass to git log. | |
| 257 tracker_acc: A tracker_access.TrackerAccess object. | |
| 258 | |
| 259 Returns: | |
| 260 A list of "Commit" objects. | |
| 261 """ | |
| 262 | |
| 263 cmd = 'cd %s && git config --get remote.cros.projectname' % path | |
| 264 projectname = _GrabOutput(cmd).strip() | |
| 265 log_fmt = '%x00%H\t%ce\t%cd\t%s\t%b' | |
| 266 cmd_fmt = 'cd %s && git log --format="%s" --date=local "%s..%s"' | |
| 267 cmd = cmd_fmt % (path, log_fmt, tag1, tag2) | |
| 268 output = _GrabOutput(cmd) | |
| 269 commits = [] | |
| 270 for log_data in output.split('\0')[1:]: | |
| 271 commit, commit_email, commit_date, subject, body = log_data.split('\t', 4) | |
| 272 change = Commit(commit, projectname, commit_email, commit_date, subject, | |
| 273 body, tracker_acc) | |
| 274 commits.append(change) | |
| 275 return commits | |
| 276 | |
| 277 | |
| 278 def _ParseArgs(): | |
| 279 """Parse command-line arguments. | |
| 280 | |
| 281 Returns: | |
| 282 An optparse.OptionParser object. | |
| 283 """ | |
| 284 parser = optparse.OptionParser() | |
| 285 parser.add_option( | |
| 286 '--sort-by-date', dest='sort_by_date', default=False, | |
| 287 action='store_true', help='Sort commits by date.') | |
| 288 parser.add_option( | |
| 289 '--tracker-user', dest='tracker_user', default=None, | |
| 290 help='Specify a username to login to code.google.com.') | |
| 291 parser.add_option( | |
| 292 '--tracker-pass', dest='tracker_pass', default=None, | |
| 293 help='Specify a password to go w/ user.') | |
| 294 parser.add_option( | |
| 295 '--tracker-passfile', dest='tracker_passfile', default=None, | |
| 296 help='Specify a file containing a password to go w/ user.') | |
| 297 return parser.parse_args() | |
| 298 | |
| 299 | |
| 300 def main(): | |
| 301 tags = _GrabTags() | |
| 302 tag1 = None | |
| 303 options, args = _ParseArgs() | |
| 304 if len(args) == 2: | |
| 305 tag1, tag2 = args | |
| 306 elif len(args) == 1: | |
| 307 tag2, = args | |
| 308 if tag2 in tags: | |
| 309 tag2_index = tags.index(tag2) | |
| 310 if tag2_index == len(tags) - 1: | |
| 311 print >>sys.stderr, 'No previous tag for %s' % tag2 | |
| 312 sys.exit(1) | |
| 313 tag1 = tags[tag2_index + 1] | |
| 314 else: | |
| 315 print >>sys.stderr, 'Unrecognized tag: %s' % tag2 | |
| 316 sys.exit(1) | |
| 317 else: | |
| 318 print >>sys.stderr, 'Usage: %s [tag1] tag2' % sys.argv[0] | |
| 319 print >>sys.stderr, 'If only one tag is specified, we view the differences' | |
| 320 print >>sys.stderr, 'between that tag and the previous tag. You can also' | |
| 321 print >>sys.stderr, 'specify cros/master to show differences with' | |
| 322 print >>sys.stderr, 'tip-of-tree.' | |
| 323 print >>sys.stderr, 'E.g. %s %s cros/master' % (sys.argv[0], tags[0]) | |
| 324 sys.exit(1) | |
| 325 | |
| 326 if options.tracker_user is not None: | |
| 327 # TODO(dianders): Once we install GData automatically, move the import | |
| 328 # to the top of the file where it belongs. It's only here to allow | |
| 329 # people to run the script without GData. | |
| 330 try: | |
| 331 import tracker_access | |
| 332 except ImportError: | |
| 333 print >>sys.stderr, INSTRS_FOR_GDATA | |
| 334 sys.exit(1) | |
| 335 if options.tracker_passfile is not None: | |
| 336 options.tracker_pass = open(options.tracker_passfile, 'r').read().strip() | |
| 337 tracker_acc = tracker_access.TrackerAccess(options.tracker_user, | |
| 338 options.tracker_pass) | |
| 339 else: | |
| 340 tracker_acc = None | |
| 341 | |
| 342 print >>sys.stderr, 'Finding differences between %s and %s' % (tag1, tag2) | |
| 343 paths = _GrabDirs() | |
| 344 changes = [] | |
| 345 for path in paths: | |
| 346 changes.extend(_GrabChanges(path, tag1, tag2, tracker_acc)) | |
| 347 | |
| 348 title = 'Changelog for %s to %s' % (tag1, tag2) | |
| 349 print '<html>' | |
| 350 print '<head><title>%s</title></head>' % title | |
| 351 print '<h1>%s</h1>' % title | |
| 352 cols = ['Project', 'Date', 'Commit', 'Committer', 'Bugs', 'Subject'] | |
| 353 print '<table border="1" cellpadding="4">' | |
| 354 print '<tr><th>%s</th>' % ('</th><th>'.join(cols)) | |
| 355 if options.sort_by_date: | |
| 356 changes.sort(key=operator.attrgetter('commit_date')) | |
| 357 else: | |
| 358 changes.sort() | |
| 359 for change in changes: | |
| 360 print change.AsHTMLTableRow() | |
| 361 print '</table>' | |
| 362 print '</html>' | |
| 363 | |
| 364 | |
| 365 if __name__ == '__main__': | |
| 366 main() | |
| OLD | NEW |