diff --git a/chromite/bin/cros_changelog b/chromite/bin/cros_changelog
index c79c2e8c56..73b5435451 100755
--- a/chromite/bin/cros_changelog
+++ b/chromite/bin/cros_changelog
@@ -17,6 +17,30 @@ 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'
@@ -38,11 +62,73 @@ def _GrabDirs():
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):
+ body, tracker_acc):
"""Create commit logs."""
self.commit = commit
self.projectname = projectname
@@ -51,10 +137,18 @@ class Commit(object):
self.commit_date = datetime.strptime(commit_date, fmt)
self.subject = subject
self.body = body
- self.bug_ids = self._GetBugIDs()
+ self._tracker_acc = tracker_acc
+ self._issues = self._GetIssues()
- def _GetBugIDs(self):
- """Get bug ID from commit logs."""
+ 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.
+ """
entries = []
for line in self.body.split('\n'):
@@ -63,7 +157,7 @@ class Commit(object):
for i in match.group(1).split(','):
entries.extend(filter(None, [x.strip() for x in i.split()]))
- bug_ids = []
+ 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)')
@@ -72,38 +166,32 @@ class Commit(object):
bug_numbers = re.findall(regex, new_item)
for bug_tuple in bug_numbers:
if bug_tuple[0] and bug_tuple[1]:
- bug_ids.append('%s:%s' % (bug_tuple[0], 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]:
- bug_ids.append('%s:%s' % (bug_tuple[2], bug_tuple[3]))
+ issues.append(Issue(bug_tuple[2], bug_tuple[3], self._tracker_acc))
last_tracker = bug_tuple[2]
elif bug_tuple[4]:
- bug_ids.append('%s:%s' % (last_tracker, bug_tuple[4]))
+ issues.append(Issue(last_tracker, bug_tuple[4], self._tracker_acc))
- bug_ids.sort(key=str.lower)
- return bug_ids
+ 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:
+
+
Blah | Blah blah |
"""
bugs = []
- bug_url_fmt = 'http://code.google.com/p/%s/issues/detail?id=%s'
link_fmt = '%s'
- for bug in self.bug_ids:
- tracker, bug_id = bug.split(':')
-
- # Get bug URL. We use short URLs to make the URLs a bit more readable.
- if tracker == 'chromium-os':
- bug_url = 'http://crosbug.com/%s' % bug_id
- elif tracker == 'chrome-os-partner':
- bug_url = 'http://crosbug.com/p/%s' % bug_id
- else:
- bug_url = bug_url_fmt % (tracker, bug_id)
-
- bugs.append(link_fmt % (bug_url, bug))
+ 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)
@@ -132,7 +220,7 @@ class Commit(object):
cmp(self.commit_date, other.commit_date))
-def _GrabChanges(path, tag1, tag2):
+def _GrabChanges(path, tag1, tag2, tracker_acc):
"""Return list of commits to path between tag1 and tag2."""
cmd = 'cd %s && git config --get remote.cros.projectname' % path
@@ -145,14 +233,25 @@ def _GrabChanges(path, tag1, tag2):
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)
+ body, tracker_acc)
commits.append(change)
return commits
+
def _ParseArgs():
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(
+ "--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()
@@ -178,11 +277,27 @@ def main():
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))
+ changes.extend(_GrabChanges(path, tag1, tag2, tracker_acc))
title = 'Changelog for %s to %s' % (tag1, tag2)
print ''
diff --git a/chromite/lib/tracker_access.py b/chromite/lib/tracker_access.py
new file mode 100644
index 0000000000..18d17ad87c
--- /dev/null
+++ b/chromite/lib/tracker_access.py
@@ -0,0 +1,166 @@
+#!/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 functions for accessing the issue tracker in a pythonic way."""
+
+import os.path
+import pprint
+import sys
+
+# import the GData libraries
+import gdata.client
+import gdata.projecthosting.client
+
+DEFAULT_TRACKER_SOURCE = "chromite-tracker-access-1.0"
+VERBOSE = True # Set to True to get extra debug info...
+
+class TrackerAccess(object):
+ """Class for accessing the tracker on code.google.com."""
+
+ def __init__(self, email="", password="",
+ tracker_source=DEFAULT_TRACKER_SOURCE):
+ """TrackerAccess constructor.
+
+ Args:
+ email: The email address to Login with; may be "" for anonymous access.
+ password: The password that goes with the email address; may be "" if
+ the email is "".
+ tracker_source: A string describing this program. This can be anything
+ you like but should should give some indication of which
+ app is making the request.
+ """
+ # Save parameters...
+ self._email = email
+ self._password = password
+ self._tracker_source = tracker_source
+
+ # This will be initted on first login...
+ self._tracker_client = None
+
+ def Login(self):
+ """Login, if needed. This may be safely called more than once.
+
+ Commands will call this function as their first line, so the client
+ of this class need not call it themselves unless trying to debug login
+ problems.
+
+ This function should be called even if we're accessing anonymously.
+ """
+ # Bail immediately if we've already logged in...
+ if self._tracker_client is not None:
+ return
+
+ self._tracker_client = gdata.projecthosting.client.ProjectHostingClient()
+ if self._email and self._password:
+ self._tracker_client.client_login(self._email, self._password,
+ source=self._tracker_source,
+ service="code", account_type='GOOGLE')
+
+ def GetKeyedLabels(self, project_name, issue_id):
+ """Get labels of the form "Key-Value" attached to the given issue.
+
+ Any labels that don't have a dash in them are ignored.
+
+ Args:
+ project_name: The tracker project to query.
+ issue_id: The ID of the issue to query; should be an int but a string
+ will probably work too.
+
+ Returns:
+ A dictionary mapping key/value pairs from the issue's labels, like:
+
+ {'Area': 'Build',
+ 'Iteration': '15',
+ 'Mstone': 'R9.x',
+ 'Pri': '1',
+ 'Type': 'Bug'}
+ """
+ # Login if needed...
+ self.Login()
+
+ # Construct the query...
+ query = gdata.projecthosting.client.Query(issue_id=issue_id)
+ try:
+ feed = self._tracker_client.get_issues(project_name, query=query)
+ except gdata.client.RequestError, e:
+ if VERBOSE:
+ print >>sys.stderr, "ERROR: Unable to access bug %s:%s: %s" % (
+ project_name, issue_id, str(e))
+ return {}
+
+ # There should be exactly one result...
+ assert len(feed.entry) == 1, "Expected exactly 1 result"
+ (entry,) = feed.entry
+
+ # We only care about labels that look like: Key-Value
+ # We'll return a dictionary of those.
+ keyed_labels = {}
+ for label in entry.label:
+ if "-" in label.text:
+ label_key, label_val = label.text.split("-", 1)
+ keyed_labels[label_key] = label_val
+
+ return keyed_labels
+
+
+def _TestGetKeyedLabels(project_name, email, passwordFile, *bug_ids):
+ """Test code for GetKeyedLabels().
+
+ Args:
+ project_name: The name of the project we're looking at.
+ email: The email address to use to login. May be ""
+ passwordFile: A file containing the password for the email address.
+ May be "" if email is "" for anon access.
+ bug_ids: A list of bug IDs to query.
+ """
+ # If password was specified as a file, read it.
+ if passwordFile:
+ password = open(passwordFile, "r").read().strip()
+ else:
+ password = ""
+
+ ta = TrackerAccess(email, password)
+
+ if not bug_ids:
+ print "No bugs were specified"
+ else:
+ for bug_id in bug_ids:
+ print bug_id, ta.GetKeyedLabels(project_name, int(bug_id))
+
+
+def _DoHelp(commands, *args):
+ """Print help for the script."""
+
+ if len(args) >= 2 and args[0] == "help" and args[1] in commands:
+ # If called with arguments 'help' and 'command', show that commands's doc.
+ command_name = args[1]
+ print commands[command_name].__doc__
+ else:
+ # Something else: show generic help...
+ print (
+ "Usage %s \n"
+ "\n"
+ "Known commands: \n"
+ " %s\n"
+ ) % (sys.argv[0], pprint.pformat(["help"] + sorted(commands)))
+
+
+def main():
+ """Main function of the script."""
+
+ commands = {
+ "TestGetKeyedLabels": _TestGetKeyedLabels,
+ }
+
+ if len(sys.argv) <= 1 or sys.argv[1] not in commands:
+ # Argument 1 isn't in list of commands; show help and pass all arguments...
+ _DoHelp(commands, *sys.argv[1:])
+ else:
+ command_name = sys.argv[1]
+ commands[command_name](*sys.argv[2:])
+
+if __name__ == "__main__":
+ main()