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