my_activity.py: update to use oauth for projecthosting

BUG=491889

Review URL: https://codereview.chromium.org/1176243002

git-svn-id: svn://svn.chromium.org/chrome/trunk/tools/depot_tools@295626 0039d316-1c4b-4281-b951-d872f2087c98
diff --git a/auth.py b/auth.py
index 70e706e..6e0d2f3 100644
--- a/auth.py
+++ b/auth.py
@@ -48,6 +48,11 @@
 # use userinfo.email scope for authentication.
 OAUTH_SCOPES = 'https://www.googleapis.com/auth/userinfo.email'
 
+# Additional OAuth scopes.
+ADDITIONAL_SCOPES = {
+  'code.google.com': 'https://www.googleapis.com/auth/projecthosting',
+}
+
 # Path to a file with cached OAuth2 credentials used by default relative to the
 # home dir (see _get_token_cache_path). It should be a safe location accessible
 # only to a current user: knowing content of this file is roughly equivalent to
@@ -219,11 +224,15 @@
   # Append some scheme, otherwise urlparse puts hostname into parsed.path.
   if '://' not in hostname:
     hostname = 'https://' + hostname
+  scopes = OAUTH_SCOPES
   parsed = urlparse.urlparse(hostname)
+  if parsed.netloc in ADDITIONAL_SCOPES:
+    scopes = "%s %s" % (scopes, ADDITIONAL_SCOPES[parsed.netloc])
+
   if parsed.path or parsed.params or parsed.query or parsed.fragment:
     raise AuthenticationError(
         'Expecting a hostname or root host URL, got %s instead' % hostname)
-  return Authenticator(parsed.netloc, config)
+  return Authenticator(parsed.netloc, config, scopes)
 
 
 class Authenticator(object):
@@ -235,7 +244,7 @@
     config: AuthConfig object that holds authentication configuration.
   """
 
-  def __init__(self, token_cache_key, config):
+  def __init__(self, token_cache_key, config, scopes):
     assert isinstance(config, AuthConfig)
     assert config.use_oauth2
     self._access_token = None
@@ -243,6 +252,7 @@
     self._lock = threading.Lock()
     self._token_cache_key = token_cache_key
     self._external_token = None
+    self._scopes = scopes
     if config.refresh_token_json:
       self._external_token = _read_refresh_token_json(config.refresh_token_json)
     logging.debug('Using auth config %r', config)
@@ -487,7 +497,7 @@
         logging.debug('Requesting user to login')
         raise LoginRequiredError(self._token_cache_key)
       logging.debug('Launching OAuth browser flow')
-      credentials = _run_oauth_dance(self._config)
+      credentials = _run_oauth_dance(self._config, self._scopes)
       _log_credentials_info('new token', credentials)
 
     logging.info(
@@ -560,7 +570,7 @@
     })
 
 
-def _run_oauth_dance(config):
+def _run_oauth_dance(config, scopes):
   """Perform full 3-legged OAuth2 flow with the browser.
 
   Returns:
@@ -572,7 +582,7 @@
   flow = client.OAuth2WebServerFlow(
       OAUTH_CLIENT_ID,
       OAUTH_CLIENT_SECRET,
-      OAUTH_SCOPES,
+      scopes,
       approval_prompt='force')
 
   use_local_webserver = config.use_local_webserver
diff --git a/my_activity.py b/my_activity.py
index 8468772..e7af09e 100755
--- a/my_activity.py
+++ b/my_activity.py
@@ -42,6 +42,9 @@
 import rietveld
 from third_party import upload
 
+import auth
+from third_party import httplib2
+
 try:
   from dateutil.relativedelta import relativedelta # pylint: disable=F0401
 except ImportError:
@@ -99,10 +102,6 @@
     'url': 'chrome-internal-review.googlesource.com',
     'shorturl': 'crosreview.com/i',
   },
-  {
-    'host': 'gerrit.chromium.org',
-    'port': 29418,
-  },
 ]
 
 google_code_projects = [
@@ -132,36 +131,6 @@
   },
 ]
 
-# Uses ClientLogin to authenticate the user for Google Code issue trackers.
-def get_auth_token(email):
-  # KeyringCreds will use the system keyring on the first try, and prompt for
-  # a password on the next ones.
-  creds = upload.KeyringCreds('code.google.com', 'code.google.com', email)
-  for _ in xrange(3):
-    email, password = creds.GetUserCredentials()
-    url = 'https://www.google.com/accounts/ClientLogin'
-    data = urllib.urlencode({
-        'Email': email,
-        'Passwd': password,
-        'service': 'code',
-        'source': 'chrome-my-activity',
-        'accountType': 'GOOGLE',
-    })
-    req = urllib2.Request(url, data=data, headers={'Accept': 'text/plain'})
-    try:
-      response = urllib2.urlopen(req)
-      response_body = response.read()
-      response_dict = dict(x.split('=')
-                           for x in response_body.split('\n') if x)
-      return response_dict['Auth']
-    except urllib2.HTTPError, e:
-      print e
-
-  print 'Unable to authenticate to code.google.com.'
-  print 'Some issues may be missing.'
-  return None
-
-
 def username(email):
   """Keeps the username of an email address."""
   return email and email.split('@', 1)[0]
@@ -230,31 +199,12 @@
   # Check the codereview cookie jar to determine which Rietveld instances to
   # authenticate to.
   def check_cookies(self):
-    cookie_file = os.path.expanduser('~/.codereview_upload_cookies')
-    if not os.path.exists(cookie_file):
-      print 'No Rietveld cookie file found.'
-      cookie_jar = []
-    else:
-      cookie_jar = cookielib.MozillaCookieJar(cookie_file)
-      try:
-        cookie_jar.load()
-        print 'Found cookie file: %s' % cookie_file
-      except (cookielib.LoadError, IOError):
-        print 'Error loading Rietveld cookie file: %s' % cookie_file
-        cookie_jar = []
-
     filtered_instances = []
 
     def has_cookie(instance):
-      for cookie in cookie_jar:
-        if cookie.name == 'SACSID' and cookie.domain == instance['url']:
-          return True
-      if self.options.auth:
-        return get_yes_or_no('No cookie found for %s. Authorize for this '
-                             'instance? (may require application-specific '
-                             'password)' % instance['url'])
-      filtered_instances.append(instance)
-      return False
+      auth_config = auth.extract_auth_config_from_options(self.options)
+      a = auth.get_authenticator_for_host(instance['url'], auth_config)
+      return a.has_cached_credentials()
 
     for instance in rietveld_instances:
       instance['auth'] = has_cookie(instance)
@@ -461,108 +411,52 @@
       })
     return ret
 
-  def google_code_issue_search(self, instance):
-    time_format = '%Y-%m-%dT%T'
-    # See http://code.google.com/p/support/wiki/IssueTrackerAPI
-    # q=<owner>@chromium.org does a full text search for <owner>@chromium.org.
-    # This will accept the issue if owner is the owner or in the cc list. Might
-    # have some false positives, though.
+  def project_hosting_issue_search(self, instance):
+    auth_config = auth.extract_auth_config_from_options(self.options)
+    authenticator = auth.get_authenticator_for_host(
+        "code.google.com", auth_config)
+    http = authenticator.authorize(httplib2.Http())
+    url = "https://www.googleapis.com/projecthosting/v2/projects/%s/issues" % (
+       instance["name"])
+    epoch = datetime.utcfromtimestamp(0)
+    user_str = '%s@chromium.org' % self.user
 
-    # Don't filter normally on modified_before because it can filter out things
-    # that were modified in the time period and then modified again after it.
-    gcode_url = ('https://code.google.com/feeds/issues/p/%s/issues/full' %
-                 instance['name'])
-
-    gcode_data = urllib.urlencode({
-        'alt': 'json',
-        'max-results': '100000',
-        'q': '%s' % self.user,
-        'published-max': self.modified_before.strftime(time_format),
-        'updated-min': self.modified_after.strftime(time_format),
+    query_data = urllib.urlencode({
+      'maxResults': 10000,
+      'q': user_str,
+      'publishedMax': '%d' % (self.modified_before - epoch).total_seconds(),
+      'updatedMin': '%d' % (self.modified_after - epoch).total_seconds(),
     })
-
-    opener = urllib2.build_opener()
-    if self.google_code_auth_token:
-      opener.addheaders = [('Authorization', 'GoogleLogin auth=%s' %
-                            self.google_code_auth_token)]
-    gcode_json = None
-    try:
-      gcode_get = opener.open(gcode_url + '?' + gcode_data)
-      gcode_json = json.load(gcode_get)
-      gcode_get.close()
-    except urllib2.HTTPError, _:
-      print 'Unable to access ' + instance['name'] + ' issue tracker.'
-
-    if not gcode_json or 'entry' not in gcode_json['feed']:
+    url = url + '?' + query_data
+    _, body = http.request(url)
+    content = json.loads(body)
+    if not content:
+      print "Unable to parse %s response from projecthosting." % (
+          instance["name"])
       return []
 
-    issues = gcode_json['feed']['entry']
-    issues = map(partial(self.process_google_code_issue, instance), issues)
-    issues = filter(self.filter_issue, issues)
-    issues = sorted(issues, key=lambda i: i['modified'], reverse=True)
+    issues = []
+    if 'items' in content:
+      items = content['items']
+      for item in items:
+        issue = {
+          "header": item["title"],
+          "created": item["published"],
+          "modified": item["updated"],
+          "author": item["author"]["name"],
+          "url": "https://code.google.com/p/%s/issues/detail?id=%s" % (
+              instance["name"], item["id"]),
+          "comments": []
+        }
+        if 'owner' in item:
+          issue['owner'] = item['owner']['name']
+        else:
+          issue['owner'] = 'None'
+        if issue['owner'] == user_str or issue['author'] == user_str:
+          issues.append(issue)
+
     return issues
 
-  def process_google_code_issue(self, project, issue):
-    ret = {}
-    ret['created'] = datetime_from_google_code(issue['published']['$t'])
-    ret['modified'] = datetime_from_google_code(issue['updated']['$t'])
-
-    ret['owner'] = ''
-    if 'issues$owner' in issue:
-      ret['owner'] = issue['issues$owner']['issues$username']['$t']
-    ret['author'] = issue['author'][0]['name']['$t']
-
-    if 'shorturl' in project:
-      issue_id = issue['id']['$t']
-      issue_id = issue_id[issue_id.rfind('/') + 1:]
-      ret['url'] = 'http://%s/%d' % (project['shorturl'], int(issue_id))
-    else:
-      issue_url = issue['link'][1]
-      if issue_url['rel'] != 'alternate':
-        raise RuntimeError
-      ret['url'] = issue_url['href']
-    ret['header'] = issue['title']['$t']
-
-    ret['replies'] = self.get_google_code_issue_replies(issue)
-    return ret
-
-  def get_google_code_issue_replies(self, issue):
-    """Get all the comments on the issue."""
-    replies_url = issue['link'][0]
-    if replies_url['rel'] != 'replies':
-      raise RuntimeError
-
-    replies_data = urllib.urlencode({
-        'alt': 'json',
-        'fields': 'entry(published,author,content)',
-    })
-
-    opener = urllib2.build_opener()
-    opener.addheaders = [('Authorization', 'GoogleLogin auth=%s' %
-                          self.google_code_auth_token)]
-    try:
-      replies_get = opener.open(replies_url['href'] + '?' + replies_data)
-    except urllib2.HTTPError, _:
-      return []
-
-    replies_json = json.load(replies_get)
-    replies_get.close()
-    return self.process_google_code_issue_replies(replies_json)
-
-  @staticmethod
-  def process_google_code_issue_replies(replies):
-    if 'entry' not in replies['feed']:
-      return []
-
-    ret = []
-    for entry in replies['feed']['entry']:
-      e = {}
-      e['created'] = datetime_from_google_code(entry['published']['$t'])
-      e['content'] = entry['content']['$t']
-      e['author'] = entry['author'][0]['name']['$t']
-      ret.append(e)
-    return ret
-
   def print_heading(self, heading):
     print
     print self.options.output_format_heading.format(heading=heading)
@@ -648,10 +542,6 @@
     # required.
     pass
 
-  def auth_for_issues(self):
-    self.google_code_auth_token = (
-        get_auth_token(self.options.local_user + '@chromium.org'))
-
   def get_changes(self):
     for instance in rietveld_instances:
       self.changes += self.rietveld_search(instance, owner=self.user)
@@ -682,7 +572,7 @@
 
   def get_issues(self):
     for project in google_code_projects:
-      self.issues += self.google_code_issue_search(project)
+      self.issues += self.project_hosting_issue_search(project)
 
   def print_issues(self):
     if self.issues:
@@ -841,17 +731,18 @@
     my_activity.auth_for_changes()
   if options.reviews:
     my_activity.auth_for_reviews()
-  if options.issues:
-    my_activity.auth_for_issues()
 
   print 'Looking up activity.....'
 
-  if options.changes:
-    my_activity.get_changes()
-  if options.reviews:
-    my_activity.get_reviews()
-  if options.issues:
-    my_activity.get_issues()
+  try:
+    if options.changes:
+      my_activity.get_changes()
+    if options.reviews:
+      my_activity.get_reviews()
+    if options.issues:
+      my_activity.get_issues()
+  except auth.AuthenticationError as e:
+    print "auth.AuthenticationError: %s" % e
 
   print '\n\n\n'