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 |