Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/services/api_svc_v1.py b/services/api_svc_v1.py
new file mode 100644
index 0000000..20a9c8b
--- /dev/null
+++ b/services/api_svc_v1.py
@@ -0,0 +1,1511 @@
+# Copyright 2016 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style
+# license that can be found in the LICENSE file or at
+# https://developers.google.com/open-source/licenses/bsd
+
+"""API service.
+
+To manually test this API locally, use the following steps:
+1. Start the development server via 'make serve'.
+2. Start a new Chrome session via the command-line:
+  PATH_TO_CHROME --user-data-dir=/tmp/test \
+  --unsafely-treat-insecure-origin-as-secure=http://localhost:8080
+3. Visit http://localhost:8080/_ah/api/explorer
+4. Click shield icon in the omnibar and allow unsafe scripts.
+5. Click on the "Services" menu item in the API Explorer.
+"""
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import calendar
+import datetime
+import endpoints
+import functools
+import logging
+import re
+import time
+from google.appengine.api import oauth
+from protorpc import message_types
+from protorpc import protojson
+from protorpc import remote
+
+import settings
+from businesslogic import work_env
+from features import filterrules_helpers
+from features import send_notifications
+from framework import authdata
+from framework import exceptions
+from framework import framework_constants
+from framework import framework_helpers
+from framework import framework_views
+from framework import monitoring
+from framework import monorailrequest
+from framework import permissions
+from framework import ratelimiter
+from framework import sql
+from project import project_helpers
+from proto import api_pb2_v1
+from proto import project_pb2
+from proto import tracker_pb2
+from search import frontendsearchpipeline
+from services import api_pb2_v1_helpers
+from services import client_config_svc
+from services import service_manager
+from services import tracker_fulltext
+from sitewide import sitewide_helpers
+from tracker import field_helpers
+from tracker import issuedetailezt
+from tracker import tracker_bizobj
+from tracker import tracker_constants
+from tracker import tracker_helpers
+
+from infra_libs import ts_mon
+
+
+ENDPOINTS_API_NAME = 'monorail'
+DOC_URL = (
+    'https://chromium.googlesource.com/infra/infra/+/main/'
+    'appengine/monorail/doc/api.md')
+
+
+def monorail_api_method(
+    request_message, response_message, **kwargs):
+  """Extends endpoints.method by performing base checks."""
+  time_fn = kwargs.pop('time_fn', time.time)
+  method_name = kwargs.get('name', '')
+  method_path = kwargs.get('path', '')
+  http_method = kwargs.get('http_method', '')
+  def new_decorator(func):
+    @endpoints.method(request_message, response_message, **kwargs)
+    @functools.wraps(func)
+    def wrapper(self, *args, **kwargs):
+      start_time = time_fn()
+      approximate_http_status = 200
+      request = args[0]
+      ret = None
+      c_id = None
+      c_email = None
+      mar = None
+      try:
+        if settings.read_only and http_method.lower() != 'get':
+          raise permissions.PermissionException(
+              'This request is not allowed in read-only mode')
+        requester = endpoints.get_current_user()
+        logging.info('requester is %r', requester)
+        logging.info('args is %r', args)
+        logging.info('kwargs is %r', kwargs)
+        auth_client_ids, auth_emails = (
+            client_config_svc.GetClientConfigSvc().GetClientIDEmails())
+        if settings.local_mode:
+          auth_client_ids.append(endpoints.API_EXPLORER_CLIENT_ID)
+        if self._services is None:
+          self._set_services(service_manager.set_up_services())
+        cnxn = sql.MonorailConnection()
+        c_id, c_email = api_base_checks(
+            request, requester, self._services, cnxn,
+            auth_client_ids, auth_emails)
+        mar = self.mar_factory(request, cnxn)
+        self.ratelimiter.CheckStart(c_id, c_email, start_time)
+        monitoring.IncrementAPIRequestsCount(
+            'endpoints', c_id, client_email=c_email)
+        ret = func(self, mar, *args, **kwargs)
+      except exceptions.NoSuchUserException as e:
+        approximate_http_status = 404
+        raise endpoints.NotFoundException(
+            'The user does not exist: %s' % str(e))
+      except (exceptions.NoSuchProjectException,
+              exceptions.NoSuchIssueException,
+              exceptions.NoSuchComponentException) as e:
+        approximate_http_status = 404
+        raise endpoints.NotFoundException(str(e))
+      except (permissions.BannedUserException,
+              permissions.PermissionException) as e:
+        approximate_http_status = 403
+        logging.info('Allowlist ID %r email %r', auth_client_ids, auth_emails)
+        raise endpoints.ForbiddenException(str(e))
+      except endpoints.BadRequestException:
+        approximate_http_status = 400
+        raise
+      except endpoints.UnauthorizedException:
+        approximate_http_status = 401
+        # Client will refresh token and retry.
+        raise
+      except oauth.InvalidOAuthTokenError:
+        approximate_http_status = 401
+        # Client will refresh token and retry.
+        raise endpoints.UnauthorizedException(
+            'Auth error: InvalidOAuthTokenError')
+      except (exceptions.GroupExistsException,
+              exceptions.InvalidComponentNameException,
+              ratelimiter.ApiRateLimitExceeded) as e:
+        approximate_http_status = 400
+        raise endpoints.BadRequestException(str(e))
+      except Exception as e:
+        approximate_http_status = 500
+        logging.exception('Unexpected error in monorail API')
+        raise
+      finally:
+        if mar:
+          mar.CleanUp()
+        now = time_fn()
+        if c_id and c_email:
+          self.ratelimiter.CheckEnd(c_id, c_email, now, start_time)
+        _RecordMonitoringStats(
+            start_time, request, ret, (method_name or func.__name__),
+            (method_path or func.__name__), approximate_http_status, now)
+
+      return ret
+
+    return wrapper
+  return new_decorator
+
+
+def _RecordMonitoringStats(
+    start_time,
+    request,
+    response,
+    method_name,
+    method_path,
+    approximate_http_status,
+    now=None):
+  now = now or time.time()
+  elapsed_ms = int((now - start_time) * 1000)
+  # Use the api name, not the request path, to prevent an explosion in
+  # possible field values.
+  method_identifier = (
+      ENDPOINTS_API_NAME + '.endpoints.' + method_name + '/' + method_path)
+
+  # Endpoints APIs don't return the full set of http status values.
+  fields = monitoring.GetCommonFields(
+      approximate_http_status, method_identifier)
+
+  monitoring.AddServerDurations(elapsed_ms, fields)
+  monitoring.IncrementServerResponseStatusCount(fields)
+  request_length = len(protojson.encode_message(request))
+  monitoring.AddServerRequesteBytes(request_length, fields)
+  response_length = 0
+  if response:
+    response_length = len(protojson.encode_message(response))
+  monitoring.AddServerResponseBytes(response_length, fields)
+
+
+def _is_requester_in_allowed_domains(requester):
+  if requester.email().endswith(settings.api_allowed_email_domains):
+    if framework_constants.MONORAIL_SCOPE in oauth.get_authorized_scopes(
+        framework_constants.MONORAIL_SCOPE):
+      return True
+    else:
+      logging.info("User is not authenticated with monorail scope")
+  return False
+
+def api_base_checks(request, requester, services, cnxn,
+                    auth_client_ids, auth_emails):
+  """Base checks for API users.
+
+  Args:
+    request: The HTTP request from Cloud Endpoints.
+    requester: The user who sends the request.
+    services: Services object.
+    cnxn: connection to the SQL database.
+    auth_client_ids: authorized client ids.
+    auth_emails: authorized emails when client is anonymous.
+
+  Returns:
+    Client ID and client email.
+
+  Raises:
+    endpoints.UnauthorizedException: If the requester is anonymous.
+    exceptions.NoSuchUserException: If the requester does not exist in Monorail.
+    NoSuchProjectException: If the project does not exist in Monorail.
+    permissions.BannedUserException: If the requester is banned.
+    permissions.PermissionException: If the requester does not have
+        permisssion to view.
+  """
+  valid_user = False
+  auth_err = ''
+  client_id = None
+
+  try:
+    client_id = oauth.get_client_id(framework_constants.OAUTH_SCOPE)
+    logging.info('Oauth client ID %s', client_id)
+  except oauth.Error as ex:
+    auth_err = 'oauth.Error: %s' % ex
+
+  if not requester:
+    try:
+      requester = oauth.get_current_user(framework_constants.OAUTH_SCOPE)
+      logging.info('Oauth requester %s', requester.email())
+    except oauth.Error as ex:
+      logging.info('Got oauth error: %r', ex)
+      auth_err = 'oauth.Error: %s' % ex
+
+  if client_id and requester:
+    if client_id in auth_client_ids:
+      # A allowlisted client app can make requests for any user or anon.
+      logging.info('Client ID %r is allowlisted', client_id)
+      valid_user = True
+    elif requester.email() in auth_emails:
+      # A allowlisted user account can make requests via any client app.
+      logging.info('Client email %r is allowlisted', requester.email())
+      valid_user = True
+    elif _is_requester_in_allowed_domains(requester):
+      # A user with an allowed-domain email and authenticated with the
+      # monorail scope is allowed to make requests via any client app.
+      logging.info(
+          'User email %r is within the allowed domains', requester.email())
+      valid_user = True
+    else:
+      auth_err = (
+          'Neither client ID %r nor email %r is allowlisted' %
+          (client_id, requester.email()))
+
+  if not valid_user:
+    raise endpoints.UnauthorizedException('Auth error: %s' % auth_err)
+  else:
+    logging.info('API request from user %s:%s', client_id, requester.email())
+
+  project_name = None
+  if hasattr(request, 'projectId'):
+    project_name = request.projectId
+  issue_local_id = None
+  if hasattr(request, 'issueId'):
+    issue_local_id = request.issueId
+  # This could raise exceptions.NoSuchUserException
+  requester_id = services.user.LookupUserID(cnxn, requester.email())
+  auth = authdata.AuthData.FromUserID(cnxn, requester_id, services)
+  if permissions.IsBanned(auth.user_pb, auth.user_view):
+    raise permissions.BannedUserException(
+        'The user %s has been banned from using Monorail' %
+        requester.email())
+  if project_name:
+    project = services.project.GetProjectByName(
+        cnxn, project_name)
+    if not project:
+      raise exceptions.NoSuchProjectException(
+          'Project %s does not exist' % project_name)
+    if project.state != project_pb2.ProjectState.LIVE:
+      raise permissions.PermissionException(
+          'API may not access project %s because it is not live'
+          % project_name)
+    if not permissions.UserCanViewProject(
+        auth.user_pb, auth.effective_ids, project):
+      raise permissions.PermissionException(
+          'The user %s has no permission for project %s' %
+          (requester.email(), project_name))
+    if issue_local_id:
+      # This may raise a NoSuchIssueException.
+      issue = services.issue.GetIssueByLocalID(
+          cnxn, project.project_id, issue_local_id)
+      perms = permissions.GetPermissions(
+          auth.user_pb, auth.effective_ids, project)
+      config = services.config.GetProjectConfig(cnxn, project.project_id)
+      granted_perms = tracker_bizobj.GetGrantedPerms(
+          issue, auth.effective_ids, config)
+      if not permissions.CanViewIssue(
+          auth.effective_ids, perms, project, issue,
+          granted_perms=granted_perms):
+        raise permissions.PermissionException(
+            'User is not allowed to view this issue %s:%d' %
+            (project_name, issue_local_id))
+
+  return client_id, requester.email()
+
+
+@endpoints.api(name=ENDPOINTS_API_NAME, version='v1',
+               description='Monorail API to manage issues.',
+               auth_level=endpoints.AUTH_LEVEL.NONE,
+               allowed_client_ids=endpoints.SKIP_CLIENT_ID_CHECK,
+               documentation=DOC_URL)
+class MonorailApi(remote.Service):
+
+  # Class variables. Handy to mock.
+  _services = None
+  _mar = None
+
+  ratelimiter = ratelimiter.ApiRateLimiter()
+
+  @classmethod
+  def _set_services(cls, services):
+    cls._services = services
+
+  def mar_factory(self, request, cnxn):
+    if not self._mar:
+      self._mar = monorailrequest.MonorailApiRequest(
+          request, self._services, cnxn=cnxn)
+    return self._mar
+
+  def aux_delete_comment(self, mar, request, delete=True):
+    action_name = 'delete' if delete else 'undelete'
+
+    with work_env.WorkEnv(mar, self._services) as we:
+      issue = we.GetIssueByLocalID(
+          mar.project_id, request.issueId, use_cache=False)
+      all_comments = we.ListIssueComments(issue)
+      try:
+        issue_comment = all_comments[request.commentId]
+      except IndexError:
+        raise exceptions.NoSuchIssueException(
+              'The issue %s:%d does not have comment %d.' %
+              (mar.project_name, request.issueId, request.commentId))
+
+      issue_perms = permissions.UpdateIssuePermissions(
+          mar.perms, mar.project, issue, mar.auth.effective_ids,
+          granted_perms=mar.granted_perms)
+      commenter = we.GetUser(issue_comment.user_id)
+
+      if not permissions.CanDeleteComment(
+          issue_comment, commenter, mar.auth.user_id, issue_perms):
+        raise permissions.PermissionException(
+              'User is not allowed to %s the comment %d of issue %s:%d' %
+              (action_name, request.commentId, mar.project_name,
+               request.issueId))
+
+      we.DeleteComment(issue, issue_comment, delete=delete)
+    return api_pb2_v1.IssuesCommentsDeleteResponse()
+
+  @monorail_api_method(
+      api_pb2_v1.ISSUES_COMMENTS_DELETE_REQUEST_RESOURCE_CONTAINER,
+      api_pb2_v1.IssuesCommentsDeleteResponse,
+      path='projects/{projectId}/issues/{issueId}/comments/{commentId}',
+      http_method='DELETE',
+      name='issues.comments.delete')
+  def issues_comments_delete(self, mar, request):
+    """Delete a comment."""
+    return self.aux_delete_comment(mar, request, True)
+
+  def parse_imported_reporter(self, mar, request):
+    """Handle the case where an API client is importing issues for users.
+
+    Args:
+      mar: monorail API request object including auth and perms.
+      request: A request PB that defines author and published fields.
+
+    Returns:
+      A pair (reporter_id, timestamp) with the user ID of the user to
+      attribute the comment to and timestamp of the original comment.
+      If the author field is not set, this is not an import request
+      and the comment is attributed to the API client as per normal.
+      An API client that is attempting to post on behalf of other
+      users must have the ImportComment permission in the current
+      project.
+    """
+    reporter_id = mar.auth.user_id
+    timestamp = None
+    if (request.author and request.author.name and
+        request.author.name != mar.auth.email):
+      if not mar.perms.HasPerm(
+          permissions.IMPORT_COMMENT, mar.auth.user_id, mar.project):
+        logging.info('name is %r', request.author.name)
+        raise permissions.PermissionException(
+            'User is not allowed to attribue comments to others')
+      reporter_id = self._services.user.LookupUserID(
+              mar.cnxn, request.author.name, autocreate=True)
+      logging.info('Importing issue or comment.')
+      if request.published:
+        timestamp = calendar.timegm(request.published.utctimetuple())
+
+    return reporter_id, timestamp
+
+  @monorail_api_method(
+      api_pb2_v1.ISSUES_COMMENTS_INSERT_REQUEST_RESOURCE_CONTAINER,
+      api_pb2_v1.IssuesCommentsInsertResponse,
+      path='projects/{projectId}/issues/{issueId}/comments',
+      http_method='POST',
+      name='issues.comments.insert')
+  def issues_comments_insert(self, mar, request):
+    # type (...) -> proto.api_pb2_v1.IssuesCommentsInsertResponse
+    """Add a comment."""
+    # Because we will modify issues, load from DB rather than cache.
+    issue = self._services.issue.GetIssueByLocalID(
+        mar.cnxn, mar.project_id, request.issueId, use_cache=False)
+    old_owner_id = tracker_bizobj.GetOwnerId(issue)
+    if not permissions.CanCommentIssue(
+        mar.auth.effective_ids, mar.perms, mar.project, issue,
+        mar.granted_perms):
+      raise permissions.PermissionException(
+          'User is not allowed to comment this issue (%s, %d)' %
+          (request.projectId, request.issueId))
+
+    # Temporary block on updating approval subfields.
+    if request.updates and request.updates.fieldValues:
+      fds_by_name = {fd.field_name.lower():fd for fd in mar.config.field_defs}
+      for fv in request.updates.fieldValues:
+        # Checking for fv.approvalName is unreliable since it can be removed.
+        fd = fds_by_name.get(fv.fieldName.lower())
+        if fd and fd.approval_id:
+          raise exceptions.ActionNotSupported(
+              'No API support for approval field changes: (approval %s owns %s)'
+              % (fd.approval_id, fd.field_name))
+        # if fd was None, that gets dealt with later.
+
+    if request.content and len(
+        request.content) > tracker_constants.MAX_COMMENT_CHARS:
+      raise endpoints.BadRequestException(
+          'Comment is too long on this issue (%s, %d' %
+          (request.projectId, request.issueId))
+
+    updates_dict = {}
+    move_to_project = None
+    if request.updates:
+      if not permissions.CanEditIssue(
+          mar.auth.effective_ids, mar.perms, mar.project, issue,
+          mar.granted_perms):
+        raise permissions.PermissionException(
+            'User is not allowed to edit this issue (%s, %d)' %
+            (request.projectId, request.issueId))
+      if request.updates.moveToProject:
+        move_to = request.updates.moveToProject.lower()
+        move_to_project = issuedetailezt.CheckMoveIssueRequest(
+            self._services, mar, issue, True, move_to, mar.errors)
+        if mar.errors.AnyErrors():
+          raise endpoints.BadRequestException(mar.errors.move_to)
+
+      updates_dict['summary'] = request.updates.summary
+      updates_dict['status'] = request.updates.status
+      updates_dict['is_description'] = request.updates.is_description
+      if request.updates.owner:
+        # A current issue owner can be removed via the API with a
+        # NO_USER_NAME('----') input.
+        if request.updates.owner == framework_constants.NO_USER_NAME:
+          updates_dict['owner'] = framework_constants.NO_USER_SPECIFIED
+        else:
+          new_owner_id = self._services.user.LookupUserID(
+              mar.cnxn, request.updates.owner)
+          valid, msg = tracker_helpers.IsValidIssueOwner(
+              mar.cnxn, mar.project, new_owner_id, self._services)
+          if not valid:
+            raise endpoints.BadRequestException(msg)
+          updates_dict['owner'] = new_owner_id
+      updates_dict['cc_add'], updates_dict['cc_remove'] = (
+          api_pb2_v1_helpers.split_remove_add(request.updates.cc))
+      updates_dict['cc_add'] = list(self._services.user.LookupUserIDs(
+          mar.cnxn, updates_dict['cc_add'], autocreate=True).values())
+      updates_dict['cc_remove'] = list(self._services.user.LookupUserIDs(
+          mar.cnxn, updates_dict['cc_remove']).values())
+      updates_dict['labels_add'], updates_dict['labels_remove'] = (
+          api_pb2_v1_helpers.split_remove_add(request.updates.labels))
+      blocked_on_add_strs, blocked_on_remove_strs = (
+          api_pb2_v1_helpers.split_remove_add(request.updates.blockedOn))
+      updates_dict['blocked_on_add'] = api_pb2_v1_helpers.issue_global_ids(
+          blocked_on_add_strs, issue.project_id, mar,
+          self._services)
+      updates_dict['blocked_on_remove'] = api_pb2_v1_helpers.issue_global_ids(
+          blocked_on_remove_strs, issue.project_id, mar,
+          self._services)
+      blocking_add_strs, blocking_remove_strs = (
+          api_pb2_v1_helpers.split_remove_add(request.updates.blocking))
+      updates_dict['blocking_add'] = api_pb2_v1_helpers.issue_global_ids(
+          blocking_add_strs, issue.project_id, mar,
+          self._services)
+      updates_dict['blocking_remove'] = api_pb2_v1_helpers.issue_global_ids(
+          blocking_remove_strs, issue.project_id, mar,
+          self._services)
+      components_add_strs, components_remove_strs = (
+          api_pb2_v1_helpers.split_remove_add(request.updates.components))
+      updates_dict['components_add'] = (
+          api_pb2_v1_helpers.convert_component_ids(
+              mar.config, components_add_strs))
+      updates_dict['components_remove'] = (
+          api_pb2_v1_helpers.convert_component_ids(
+              mar.config, components_remove_strs))
+      if request.updates.mergedInto:
+        merge_project_name, merge_local_id = tracker_bizobj.ParseIssueRef(
+            request.updates.mergedInto)
+        merge_into_project = self._services.project.GetProjectByName(
+            mar.cnxn, merge_project_name or issue.project_name)
+        # Because we will modify issues, load from DB rather than cache.
+        merge_into_issue = self._services.issue.GetIssueByLocalID(
+            mar.cnxn, merge_into_project.project_id, merge_local_id,
+            use_cache=False)
+        merge_allowed = tracker_helpers.IsMergeAllowed(
+            merge_into_issue, mar, self._services)
+        if not merge_allowed:
+          raise permissions.PermissionException(
+            'User is not allowed to merge into issue %s:%s' %
+            (merge_into_issue.project_name, merge_into_issue.local_id))
+        updates_dict['merged_into'] = merge_into_issue.issue_id
+      (updates_dict['field_vals_add'], updates_dict['field_vals_remove'],
+       updates_dict['fields_clear'], updates_dict['fields_labels_add'],
+       updates_dict['fields_labels_remove']) = (
+          api_pb2_v1_helpers.convert_field_values(
+              request.updates.fieldValues, mar, self._services))
+
+    field_helpers.ValidateCustomFields(
+        mar.cnxn, self._services,
+        (updates_dict.get('field_vals_add', []) +
+         updates_dict.get('field_vals_remove', [])),
+        mar.config, mar.project, ezt_errors=mar.errors)
+    if mar.errors.AnyErrors():
+      raise endpoints.BadRequestException(
+          'Invalid field values: %s' % mar.errors.custom_fields)
+
+    updates_dict['labels_add'] = (
+        updates_dict.get('labels_add', []) +
+        updates_dict.get('fields_labels_add', []))
+    updates_dict['labels_remove'] = (
+        updates_dict.get('labels_remove', []) +
+        updates_dict.get('fields_labels_remove', []))
+
+    # TODO(jrobbins): Stop using updates_dict in the first place.
+    delta = tracker_bizobj.MakeIssueDelta(
+        updates_dict.get('status'),
+        updates_dict.get('owner'),
+        updates_dict.get('cc_add', []),
+        updates_dict.get('cc_remove', []),
+        updates_dict.get('components_add', []),
+        updates_dict.get('components_remove', []),
+        (updates_dict.get('labels_add', []) +
+         updates_dict.get('fields_labels_add', [])),
+        (updates_dict.get('labels_remove', []) +
+         updates_dict.get('fields_labels_remove', [])),
+        updates_dict.get('field_vals_add', []),
+        updates_dict.get('field_vals_remove', []),
+        updates_dict.get('fields_clear', []),
+        updates_dict.get('blocked_on_add', []),
+        updates_dict.get('blocked_on_remove', []),
+        updates_dict.get('blocking_add', []),
+        updates_dict.get('blocking_remove', []),
+        updates_dict.get('merged_into'),
+        updates_dict.get('summary'))
+
+    importer_id = None
+    reporter_id, timestamp = self.parse_imported_reporter(mar, request)
+    if reporter_id != mar.auth.user_id:
+      importer_id = mar.auth.user_id
+
+    # TODO(jrobbins): Finish refactoring to make everything go through work_env.
+    _, comment = self._services.issue.DeltaUpdateIssue(
+        cnxn=mar.cnxn, services=self._services,
+        reporter_id=reporter_id, project_id=mar.project_id, config=mar.config,
+        issue=issue, delta=delta, index_now=False, comment=request.content,
+        is_description=updates_dict.get('is_description'),
+        timestamp=timestamp, importer_id=importer_id)
+
+    move_comment = None
+    if move_to_project:
+      old_text_ref = 'issue %s:%s' % (issue.project_name, issue.local_id)
+      tracker_fulltext.UnindexIssues([issue.issue_id])
+      moved_back_iids = self._services.issue.MoveIssues(
+          mar.cnxn, move_to_project, [issue], self._services.user)
+      new_text_ref = 'issue %s:%s' % (issue.project_name, issue.local_id)
+      if issue.issue_id in moved_back_iids:
+        content = 'Moved %s back to %s again.' % (old_text_ref, new_text_ref)
+      else:
+        content = 'Moved %s to now be %s.' % (old_text_ref, new_text_ref)
+      move_comment = self._services.issue.CreateIssueComment(
+        mar.cnxn, issue, mar.auth.user_id, content, amendments=[
+            tracker_bizobj.MakeProjectAmendment(move_to_project.project_name)])
+
+    if 'merged_into' in updates_dict:
+      new_starrers = tracker_helpers.GetNewIssueStarrers(
+          mar.cnxn, self._services, [issue.issue_id], merge_into_issue.issue_id)
+      tracker_helpers.AddIssueStarrers(
+          mar.cnxn, self._services, mar,
+          merge_into_issue.issue_id, merge_into_project, new_starrers)
+      # Load target issue again to get the updated star count.
+      merge_into_issue = self._services.issue.GetIssue(
+        mar.cnxn, merge_into_issue.issue_id, use_cache=False)
+      merge_comment_pb = tracker_helpers.MergeCCsAndAddComment(
+        self._services, mar, issue, merge_into_issue)
+      hostport = framework_helpers.GetHostPort(
+          project_name=merge_into_issue.project_name)
+      send_notifications.PrepareAndSendIssueChangeNotification(
+          merge_into_issue.issue_id, hostport,
+          mar.auth.user_id, send_email=True, comment_id=merge_comment_pb.id)
+
+    tracker_fulltext.IndexIssues(
+        mar.cnxn, [issue], self._services.user, self._services.issue,
+        self._services.config)
+
+    comment = comment or move_comment
+    if comment is None:
+      return api_pb2_v1.IssuesCommentsInsertResponse()
+
+    cmnts = self._services.issue.GetCommentsForIssue(mar.cnxn, issue.issue_id)
+    seq = len(cmnts) - 1
+
+    if request.sendEmail:
+      hostport = framework_helpers.GetHostPort(project_name=issue.project_name)
+      send_notifications.PrepareAndSendIssueChangeNotification(
+          issue.issue_id, hostport, comment.user_id, send_email=True,
+          old_owner_id=old_owner_id, comment_id=comment.id)
+
+    issue_perms = permissions.UpdateIssuePermissions(
+        mar.perms, mar.project, issue, mar.auth.effective_ids,
+        granted_perms=mar.granted_perms)
+    commenter = self._services.user.GetUser(mar.cnxn, comment.user_id)
+    can_delete = permissions.CanDeleteComment(
+        comment, commenter, mar.auth.user_id, issue_perms)
+    return api_pb2_v1.IssuesCommentsInsertResponse(
+        id=seq,
+        kind='monorail#issueComment',
+        author=api_pb2_v1_helpers.convert_person(
+            comment.user_id, mar.cnxn, self._services),
+        content=comment.content,
+        published=datetime.datetime.fromtimestamp(comment.timestamp),
+        updates=api_pb2_v1_helpers.convert_amendments(
+            issue, comment.amendments, mar, self._services),
+        canDelete=can_delete)
+
+  @monorail_api_method(
+      api_pb2_v1.ISSUES_COMMENTS_LIST_REQUEST_RESOURCE_CONTAINER,
+      api_pb2_v1.IssuesCommentsListResponse,
+      path='projects/{projectId}/issues/{issueId}/comments',
+      http_method='GET',
+      name='issues.comments.list')
+  def issues_comments_list(self, mar, request):
+    """List all comments for an issue."""
+    issue = self._services.issue.GetIssueByLocalID(
+        mar.cnxn, mar.project_id, request.issueId)
+    comments = self._services.issue.GetCommentsForIssue(
+        mar.cnxn, issue.issue_id)
+    comments = [comment for comment in comments if not comment.approval_id]
+    visible_comments = []
+    for comment in comments[
+        request.startIndex:(request.startIndex + request.maxResults)]:
+      visible_comments.append(
+          api_pb2_v1_helpers.convert_comment(
+              issue, comment, mar, self._services, mar.granted_perms))
+
+    return api_pb2_v1.IssuesCommentsListResponse(
+        kind='monorail#issueCommentList',
+        totalResults=len(comments),
+        items=visible_comments)
+
+  @monorail_api_method(
+      api_pb2_v1.ISSUES_COMMENTS_DELETE_REQUEST_RESOURCE_CONTAINER,
+      api_pb2_v1.IssuesCommentsDeleteResponse,
+      path='projects/{projectId}/issues/{issueId}/comments/{commentId}',
+      http_method='POST',
+      name='issues.comments.undelete')
+  def issues_comments_undelete(self, mar, request):
+    """Restore a deleted comment."""
+    return self.aux_delete_comment(mar, request, False)
+
+  @monorail_api_method(
+      api_pb2_v1.APPROVALS_COMMENTS_LIST_REQUEST_RESOURCE_CONTAINER,
+      api_pb2_v1.ApprovalsCommentsListResponse,
+      path='projects/{projectId}/issues/{issueId}/'
+            'approvals/{approvalName}/comments',
+      http_method='GET',
+      name='approvals.comments.list')
+  def approvals_comments_list(self, mar, request):
+    """List all comments for an issue approval."""
+    issue = self._services.issue.GetIssueByLocalID(
+        mar.cnxn, mar.project_id, request.issueId)
+    if not permissions.CanViewIssue(
+        mar.auth.effective_ids, mar.perms, mar.project, issue,
+        mar.granted_perms):
+      raise permissions.PermissionException(
+          'User is not allowed to view this issue (%s, %d)' %
+          (request.projectId, request.issueId))
+    config = self._services.config.GetProjectConfig(mar.cnxn, issue.project_id)
+    approval_fd = tracker_bizobj.FindFieldDef(request.approvalName, config)
+    if not approval_fd:
+      raise endpoints.BadRequestException(
+          'Field definition for %s not found in project config' %
+          request.approvalName)
+    comments = self._services.issue.GetCommentsForIssue(
+        mar.cnxn, issue.issue_id)
+    comments = [comment for comment in comments
+                if comment.approval_id == approval_fd.field_id]
+    visible_comments = []
+    for comment in comments[
+        request.startIndex:(request.startIndex + request.maxResults)]:
+      visible_comments.append(
+          api_pb2_v1_helpers.convert_approval_comment(
+              issue, comment, mar, self._services, mar.granted_perms))
+
+    return api_pb2_v1.ApprovalsCommentsListResponse(
+        kind='monorail#approvalCommentList',
+        totalResults=len(comments),
+        items=visible_comments)
+
+  @monorail_api_method(
+      api_pb2_v1.APPROVALS_COMMENTS_INSERT_REQUEST_RESOURCE_CONTAINER,
+      api_pb2_v1.ApprovalsCommentsInsertResponse,
+      path=("projects/{projectId}/issues/{issueId}/"
+            "approvals/{approvalName}/comments"),
+      http_method='POST',
+      name='approvals.comments.insert')
+  def approvals_comments_insert(self, mar, request):
+    # type (...) -> proto.api_pb2_v1.ApprovalsCommentsInsertResponse
+    """Add an approval comment."""
+    approval_fd = tracker_bizobj.FindFieldDef(
+        request.approvalName, mar.config)
+    if not approval_fd or (
+        approval_fd.field_type != tracker_pb2.FieldTypes.APPROVAL_TYPE):
+      raise endpoints.BadRequestException(
+          'Field definition for %s not found in project config' %
+          request.approvalName)
+    try:
+      issue = self._services.issue.GetIssueByLocalID(
+          mar.cnxn, mar.project_id, request.issueId)
+    except exceptions.NoSuchIssueException:
+      raise endpoints.BadRequestException(
+          'Issue %s:%s not found' % (request.projectId, request.issueId))
+    approval = tracker_bizobj.FindApprovalValueByID(
+        approval_fd.field_id, issue.approval_values)
+    if not approval:
+      raise endpoints.BadRequestException(
+          'Approval %s not found in issue.' % request.approvalName)
+
+    if not permissions.CanCommentIssue(
+        mar.auth.effective_ids, mar.perms, mar.project, issue,
+        mar.granted_perms):
+      raise permissions.PermissionException(
+          'User is not allowed to comment on this issue (%s, %d)' %
+          (request.projectId, request.issueId))
+
+    if request.content and len(
+        request.content) > tracker_constants.MAX_COMMENT_CHARS:
+      raise endpoints.BadRequestException(
+          'Comment is too long on this issue (%s, %d' %
+          (request.projectId, request.issueId))
+
+    updates_dict = {}
+    if request.approvalUpdates:
+      if request.approvalUpdates.fieldValues:
+        # Block updating field values that don't belong to the approval.
+        approvals_fds_by_name = {
+            fd.field_name.lower():fd for fd in mar.config.field_defs
+            if fd.approval_id == approval_fd.field_id}
+        for fv in request.approvalUpdates.fieldValues:
+          if approvals_fds_by_name.get(fv.fieldName.lower()) is None:
+            raise endpoints.BadRequestException(
+              'Field defition for %s not found in %s subfields.' %
+              (fv.fieldName, request.approvalName))
+        (updates_dict['field_vals_add'], updates_dict['field_vals_remove'],
+         updates_dict['fields_clear'], updates_dict['fields_labels_add'],
+         updates_dict['fields_labels_remove']) = (
+             api_pb2_v1_helpers.convert_field_values(
+                 request.approvalUpdates.fieldValues, mar, self._services))
+      if request.approvalUpdates.approvers:
+        if not permissions.CanUpdateApprovers(
+            mar.auth.effective_ids, mar.perms, mar.project,
+            approval.approver_ids):
+          raise permissions.PermissionException(
+              'User is not allowed to update approvers')
+        approvers_add, approvers_remove = api_pb2_v1_helpers.split_remove_add(
+            request.approvalUpdates.approvers)
+        updates_dict['approver_ids_add'] = list(
+            self._services.user.LookupUserIDs(mar.cnxn, approvers_add,
+              autocreate=True).values())
+        updates_dict['approver_ids_remove'] = list(
+            self._services.user.LookupUserIDs(mar.cnxn, approvers_remove,
+              autocreate=True).values())
+      if request.approvalUpdates.status:
+        status = tracker_pb2.ApprovalStatus(
+            api_pb2_v1.ApprovalStatus(request.approvalUpdates.status).number)
+        if not permissions.CanUpdateApprovalStatus(
+            mar.auth.effective_ids, mar.perms, mar.project,
+            approval.approver_ids, status):
+          raise permissions.PermissionException(
+              'User is not allowed to make this status change')
+        updates_dict['status'] = status
+    logging.info(time.time)
+    approval_delta = tracker_bizobj.MakeApprovalDelta(
+        updates_dict.get('status'), mar.auth.user_id,
+        updates_dict.get('approver_ids_add', []),
+        updates_dict.get('approver_ids_remove', []),
+        updates_dict.get('field_vals_add', []),
+        updates_dict.get('field_vals_remove', []),
+        updates_dict.get('fields_clear', []),
+        updates_dict.get('fields_labels_add', []),
+        updates_dict.get('fields_labels_remove', []))
+    comment = self._services.issue.DeltaUpdateIssueApproval(
+        mar.cnxn, mar.auth.user_id, mar.config, issue, approval, approval_delta,
+        comment_content=request.content,
+        is_description=request.is_description)
+
+    cmnts = self._services.issue.GetCommentsForIssue(mar.cnxn, issue.issue_id)
+    seq = len(cmnts) - 1
+
+    if request.sendEmail:
+      hostport = framework_helpers.GetHostPort(project_name=issue.project_name)
+      send_notifications.PrepareAndSendApprovalChangeNotification(
+          issue.issue_id, approval.approval_id,
+          hostport, comment.id, send_email=True)
+
+    issue_perms = permissions.UpdateIssuePermissions(
+        mar.perms, mar.project, issue, mar.auth.effective_ids,
+        granted_perms=mar.granted_perms)
+    commenter = self._services.user.GetUser(mar.cnxn, comment.user_id)
+    can_delete = permissions.CanDeleteComment(
+        comment, commenter, mar.auth.user_id, issue_perms)
+    return api_pb2_v1.ApprovalsCommentsInsertResponse(
+        id=seq,
+        kind='monorail#approvalComment',
+        author=api_pb2_v1_helpers.convert_person(
+            comment.user_id, mar.cnxn, self._services),
+        content=comment.content,
+        published=datetime.datetime.fromtimestamp(comment.timestamp),
+        approvalUpdates=api_pb2_v1_helpers.convert_approval_amendments(
+            comment.amendments, mar, self._services),
+        canDelete=can_delete)
+
+  @monorail_api_method(
+      api_pb2_v1.USERS_GET_REQUEST_RESOURCE_CONTAINER,
+      api_pb2_v1.UsersGetResponse,
+      path='users/{userId}',
+      http_method='GET',
+      name='users.get')
+  def users_get(self, mar, request):
+    """Get a user."""
+    owner_project_only = request.ownerProjectsOnly
+    with work_env.WorkEnv(mar, self._services) as we:
+      (visible_ownership, visible_deleted, visible_membership,
+       visible_contrib) = we.GetUserProjects(
+           mar.viewed_user_auth.effective_ids)
+
+    project_list = []
+    for proj in (visible_ownership + visible_deleted):
+      config = self._services.config.GetProjectConfig(
+          mar.cnxn, proj.project_id)
+      templates = self._services.template.GetProjectTemplates(
+          mar.cnxn, config.project_id)
+      proj_result = api_pb2_v1_helpers.convert_project(
+          proj, config, api_pb2_v1.Role.owner, templates)
+      project_list.append(proj_result)
+    if not owner_project_only:
+      for proj in visible_membership:
+        config = self._services.config.GetProjectConfig(
+            mar.cnxn, proj.project_id)
+        templates = self._services.template.GetProjectTemplates(
+            mar.cnxn, config.project_id)
+        proj_result = api_pb2_v1_helpers.convert_project(
+            proj, config, api_pb2_v1.Role.member, templates)
+        project_list.append(proj_result)
+      for proj in visible_contrib:
+        config = self._services.config.GetProjectConfig(
+            mar.cnxn, proj.project_id)
+        templates = self._services.template.GetProjectTemplates(
+            mar.cnxn, config.project_id)
+        proj_result = api_pb2_v1_helpers.convert_project(
+            proj, config, api_pb2_v1.Role.contributor, templates)
+        project_list.append(proj_result)
+
+    return api_pb2_v1.UsersGetResponse(
+        id=str(mar.viewed_user_auth.user_id),
+        kind='monorail#user',
+        projects=project_list,
+    )
+
+  @monorail_api_method(
+      api_pb2_v1.ISSUES_GET_REQUEST_RESOURCE_CONTAINER,
+      api_pb2_v1.IssuesGetInsertResponse,
+      path='projects/{projectId}/issues/{issueId}',
+      http_method='GET',
+      name='issues.get')
+  def issues_get(self, mar, request):
+    """Get an issue."""
+    issue = self._services.issue.GetIssueByLocalID(
+        mar.cnxn, mar.project_id, request.issueId)
+
+    return api_pb2_v1_helpers.convert_issue(
+        api_pb2_v1.IssuesGetInsertResponse, issue, mar, self._services)
+
+  @monorail_api_method(
+      api_pb2_v1.ISSUES_INSERT_REQUEST_RESOURCE_CONTAINER,
+      api_pb2_v1.IssuesGetInsertResponse,
+      path='projects/{projectId}/issues',
+      http_method='POST',
+      name='issues.insert')
+  def issues_insert(self, mar, request):
+    """Add a new issue."""
+    if not mar.perms.CanUsePerm(
+        permissions.CREATE_ISSUE, mar.auth.effective_ids, mar.project, []):
+      raise permissions.PermissionException(
+          'The requester %s is not allowed to create issues for project %s.' %
+          (mar.auth.email, mar.project_name))
+
+    with work_env.WorkEnv(mar, self._services) as we:
+      owner_id = framework_constants.NO_USER_SPECIFIED
+      if request.owner and request.owner.name:
+        try:
+          owner_id = self._services.user.LookupUserID(
+              mar.cnxn, request.owner.name)
+        except exceptions.NoSuchUserException:
+          raise endpoints.BadRequestException(
+              'The specified owner %s does not exist.' % request.owner.name)
+
+      cc_ids = []
+      request.cc = [cc for cc in request.cc if cc]
+      if request.cc:
+        cc_ids = list(self._services.user.LookupUserIDs(
+            mar.cnxn, [ap.name for ap in request.cc],
+            autocreate=True).values())
+      comp_ids = api_pb2_v1_helpers.convert_component_ids(
+          mar.config, request.components)
+      fields_add, _, _, fields_labels, _ = (
+          api_pb2_v1_helpers.convert_field_values(
+              request.fieldValues, mar, self._services))
+      field_helpers.ValidateCustomFields(
+          mar.cnxn, self._services, fields_add, mar.config, mar.project,
+          ezt_errors=mar.errors)
+      if mar.errors.AnyErrors():
+        raise endpoints.BadRequestException(
+            'Invalid field values: %s' % mar.errors.custom_fields)
+
+      logging.info('request.author is %r', request.author)
+      reporter_id, timestamp = self.parse_imported_reporter(mar, request)
+      # To preserve previous behavior, do not raise filter rule errors.
+      try:
+        new_issue, _ = we.CreateIssue(
+            mar.project_id,
+            request.summary,
+            request.status,
+            owner_id,
+            cc_ids,
+            request.labels + fields_labels,
+            fields_add,
+            comp_ids,
+            request.description,
+            blocked_on=api_pb2_v1_helpers.convert_issueref_pbs(
+                request.blockedOn, mar, self._services),
+            blocking=api_pb2_v1_helpers.convert_issueref_pbs(
+                request.blocking, mar, self._services),
+            reporter_id=reporter_id,
+            timestamp=timestamp,
+            send_email=request.sendEmail,
+            raise_filter_errors=False)
+        we.StarIssue(new_issue, True)
+      except exceptions.InputException as e:
+        raise endpoints.BadRequestException(str(e))
+
+    return api_pb2_v1_helpers.convert_issue(
+        api_pb2_v1.IssuesGetInsertResponse, new_issue, mar, self._services)
+
+  @monorail_api_method(
+      api_pb2_v1.ISSUES_LIST_REQUEST_RESOURCE_CONTAINER,
+      api_pb2_v1.IssuesListResponse,
+      path='projects/{projectId}/issues',
+      http_method='GET',
+      name='issues.list')
+  def issues_list(self, mar, request):
+    """List issues for projects."""
+    if request.additionalProject:
+      for project_name in request.additionalProject:
+        project = self._services.project.GetProjectByName(
+            mar.cnxn, project_name)
+        if project and not permissions.UserCanViewProject(
+            mar.auth.user_pb, mar.auth.effective_ids, project):
+          raise permissions.PermissionException(
+              'The user %s has no permission for project %s' %
+              (mar.auth.email, project_name))
+    # TODO(jrobbins): This should go through work_env.
+    pipeline = frontendsearchpipeline.FrontendSearchPipeline(
+        mar.cnxn,
+        self._services,
+        mar.auth, [mar.me_user_id],
+        mar.query,
+        mar.query_project_names,
+        mar.num,
+        mar.start,
+        mar.can,
+        mar.group_by_spec,
+        mar.sort_spec,
+        mar.warnings,
+        mar.errors,
+        mar.use_cached_searches,
+        mar.profiler,
+        project=mar.project)
+    if not mar.errors.AnyErrors():
+      pipeline.SearchForIIDs()
+      pipeline.MergeAndSortIssues()
+      pipeline.Paginate()
+    else:
+      raise endpoints.BadRequestException(mar.errors.query)
+
+    issue_list = [
+        api_pb2_v1_helpers.convert_issue(
+            api_pb2_v1.IssueWrapper, r, mar, self._services)
+        for r in pipeline.visible_results]
+    return api_pb2_v1.IssuesListResponse(
+        kind='monorail#issueList',
+        totalResults=pipeline.total_count,
+        items=issue_list)
+
+  @monorail_api_method(
+      api_pb2_v1.GROUPS_SETTINGS_LIST_REQUEST_RESOURCE_CONTAINER,
+      api_pb2_v1.GroupsSettingsListResponse,
+      path='groupsettings',
+      http_method='GET',
+      name='groups.settings.list')
+  def groups_settings_list(self, mar, request):
+    """List all group settings."""
+    all_groups = self._services.usergroup.GetAllUserGroupsInfo(mar.cnxn)
+    group_settings = []
+    for g in all_groups:
+      setting = g[2]
+      wrapper = api_pb2_v1_helpers.convert_group_settings(g[0], setting)
+      if not request.importedGroupsOnly or wrapper.ext_group_type:
+        group_settings.append(wrapper)
+    return api_pb2_v1.GroupsSettingsListResponse(
+        groupSettings=group_settings)
+
+  @monorail_api_method(
+      api_pb2_v1.GROUPS_CREATE_REQUEST_RESOURCE_CONTAINER,
+      api_pb2_v1.GroupsCreateResponse,
+      path='groups',
+      http_method='POST',
+      name='groups.create')
+  def groups_create(self, mar, request):
+    """Create a new user group."""
+    if not permissions.CanCreateGroup(mar.perms):
+      raise permissions.PermissionException(
+          'The user is not allowed to create groups.')
+
+    user_dict = self._services.user.LookupExistingUserIDs(
+        mar.cnxn, [request.groupName])
+    if request.groupName.lower() in user_dict:
+      raise exceptions.GroupExistsException(
+          'group %s already exists' % request.groupName)
+
+    if request.ext_group_type:
+      ext_group_type = str(request.ext_group_type).lower()
+    else:
+      ext_group_type = None
+    group_id = self._services.usergroup.CreateGroup(
+        mar.cnxn, self._services, request.groupName,
+        str(request.who_can_view_members).lower(),
+        ext_group_type)
+
+    return api_pb2_v1.GroupsCreateResponse(
+        groupID=group_id)
+
+  @monorail_api_method(
+      api_pb2_v1.GROUPS_GET_REQUEST_RESOURCE_CONTAINER,
+      api_pb2_v1.GroupsGetResponse,
+      path='groups/{groupName}',
+      http_method='GET',
+      name='groups.get')
+  def groups_get(self, mar, request):
+    """Get a group's settings and users."""
+    if not mar.viewed_user_auth:
+      raise exceptions.NoSuchUserException(request.groupName)
+    group_id = mar.viewed_user_auth.user_id
+    group_settings = self._services.usergroup.GetGroupSettings(
+        mar.cnxn, group_id)
+    member_ids, owner_ids = self._services.usergroup.LookupAllMembers(
+          mar.cnxn, [group_id])
+    (owned_project_ids, membered_project_ids,
+     contrib_project_ids) = self._services.project.GetUserRolesInAllProjects(
+         mar.cnxn, mar.auth.effective_ids)
+    project_ids = owned_project_ids.union(
+        membered_project_ids).union(contrib_project_ids)
+    if not permissions.CanViewGroupMembers(
+        mar.perms, mar.auth.effective_ids, group_settings, member_ids[group_id],
+        owner_ids[group_id], project_ids):
+      raise permissions.PermissionException(
+          'The user is not allowed to view this group.')
+
+    member_ids, owner_ids = self._services.usergroup.LookupMembers(
+        mar.cnxn, [group_id])
+
+    member_emails = list(self._services.user.LookupUserEmails(
+        mar.cnxn, member_ids[group_id]).values())
+    owner_emails = list(self._services.user.LookupUserEmails(
+        mar.cnxn, owner_ids[group_id]).values())
+
+    return api_pb2_v1.GroupsGetResponse(
+      groupID=group_id,
+      groupSettings=api_pb2_v1_helpers.convert_group_settings(
+          request.groupName, group_settings),
+      groupOwners=owner_emails,
+      groupMembers=member_emails)
+
+  @monorail_api_method(
+      api_pb2_v1.GROUPS_UPDATE_REQUEST_RESOURCE_CONTAINER,
+      api_pb2_v1.GroupsUpdateResponse,
+      path='groups/{groupName}',
+      http_method='POST',
+      name='groups.update')
+  def groups_update(self, mar, request):
+    """Update a group's settings and users."""
+    group_id = mar.viewed_user_auth.user_id
+    member_ids_dict, owner_ids_dict = self._services.usergroup.LookupMembers(
+        mar.cnxn, [group_id])
+    owner_ids = owner_ids_dict.get(group_id, [])
+    member_ids = member_ids_dict.get(group_id, [])
+    if not permissions.CanEditGroup(
+        mar.perms, mar.auth.effective_ids, owner_ids):
+      raise permissions.PermissionException(
+          'The user is not allowed to edit this group.')
+
+    group_settings = self._services.usergroup.GetGroupSettings(
+        mar.cnxn, group_id)
+    if (request.who_can_view_members or request.ext_group_type
+        or request.last_sync_time or request.friend_projects):
+      group_settings.who_can_view_members = (
+          request.who_can_view_members or group_settings.who_can_view_members)
+      group_settings.ext_group_type = (
+          request.ext_group_type or group_settings.ext_group_type)
+      group_settings.last_sync_time = (
+          request.last_sync_time or group_settings.last_sync_time)
+      if framework_constants.NO_VALUES in request.friend_projects:
+        group_settings.friend_projects = []
+      else:
+        id_dict = self._services.project.LookupProjectIDs(
+            mar.cnxn, request.friend_projects)
+        group_settings.friend_projects = (
+            list(id_dict.values()) or group_settings.friend_projects)
+      self._services.usergroup.UpdateSettings(
+          mar.cnxn, group_id, group_settings)
+
+    if request.groupOwners or request.groupMembers:
+      self._services.usergroup.RemoveMembers(
+          mar.cnxn, group_id, owner_ids + member_ids)
+      owners_dict = self._services.user.LookupUserIDs(
+          mar.cnxn, request.groupOwners, autocreate=True)
+      self._services.usergroup.UpdateMembers(
+          mar.cnxn, group_id, list(owners_dict.values()), 'owner')
+      members_dict = self._services.user.LookupUserIDs(
+          mar.cnxn, request.groupMembers, autocreate=True)
+      self._services.usergroup.UpdateMembers(
+          mar.cnxn, group_id, list(members_dict.values()), 'member')
+
+    return api_pb2_v1.GroupsUpdateResponse()
+
+  @monorail_api_method(
+      api_pb2_v1.COMPONENTS_LIST_REQUEST_RESOURCE_CONTAINER,
+      api_pb2_v1.ComponentsListResponse,
+      path='projects/{projectId}/components',
+      http_method='GET',
+      name='components.list')
+  def components_list(self, mar, _request):
+    """List all components of a given project."""
+    config = self._services.config.GetProjectConfig(mar.cnxn, mar.project_id)
+    components = [api_pb2_v1_helpers.convert_component_def(
+        cd, mar, self._services) for cd in config.component_defs]
+    return api_pb2_v1.ComponentsListResponse(
+        components=components)
+
+  @monorail_api_method(
+      api_pb2_v1.COMPONENTS_CREATE_REQUEST_RESOURCE_CONTAINER,
+      api_pb2_v1.Component,
+      path='projects/{projectId}/components',
+      http_method='POST',
+      name='components.create')
+  def components_create(self, mar, request):
+    """Create a component."""
+    if not mar.perms.CanUsePerm(
+        permissions.EDIT_PROJECT, mar.auth.effective_ids, mar.project, []):
+      raise permissions.PermissionException(
+          'User is not allowed to create components for this project')
+
+    config = self._services.config.GetProjectConfig(mar.cnxn, mar.project_id)
+    leaf_name = request.componentName
+    if not tracker_constants.COMPONENT_NAME_RE.match(leaf_name):
+      raise exceptions.InvalidComponentNameException(
+          'The component name %s is invalid.' % leaf_name)
+
+    parent_path = request.parentPath
+    if parent_path:
+      parent_def = tracker_bizobj.FindComponentDef(parent_path, config)
+      if not parent_def:
+        raise exceptions.NoSuchComponentException(
+            'Parent component %s does not exist.' % parent_path)
+      if not permissions.CanEditComponentDef(
+          mar.auth.effective_ids, mar.perms, mar.project, parent_def, config):
+        raise permissions.PermissionException(
+            'User is not allowed to add a subcomponent to component %s' %
+            parent_path)
+
+      path = '%s>%s' % (parent_path, leaf_name)
+    else:
+      path = leaf_name
+
+    if tracker_bizobj.FindComponentDef(path, config):
+      raise exceptions.InvalidComponentNameException(
+          'The name %s is already in use.' % path)
+
+    created = int(time.time())
+    user_emails = set()
+    user_emails.update([mar.auth.email] + request.admin + request.cc)
+    user_ids_dict = self._services.user.LookupUserIDs(
+        mar.cnxn, list(user_emails), autocreate=False)
+    request.admin = [admin for admin in request.admin if admin]
+    admin_ids = [user_ids_dict[uname] for uname in request.admin]
+    request.cc = [cc for cc in request.cc if cc]
+    cc_ids = [user_ids_dict[uname] for uname in request.cc]
+    label_ids = []  # TODO(jrobbins): allow API clients to specify this too.
+
+    component_id = self._services.config.CreateComponentDef(
+        mar.cnxn, mar.project_id, path, request.description, request.deprecated,
+        admin_ids, cc_ids, created, user_ids_dict[mar.auth.email], label_ids)
+
+    return api_pb2_v1.Component(
+        componentId=component_id,
+        projectName=request.projectId,
+        componentPath=path,
+        description=request.description,
+        admin=request.admin,
+        cc=request.cc,
+        deprecated=request.deprecated,
+        created=datetime.datetime.fromtimestamp(created),
+        creator=mar.auth.email)
+
+  @monorail_api_method(
+      api_pb2_v1.COMPONENTS_DELETE_REQUEST_RESOURCE_CONTAINER,
+      message_types.VoidMessage,
+      path='projects/{projectId}/components/{componentPath}',
+      http_method='DELETE',
+      name='components.delete')
+  def components_delete(self, mar, request):
+    """Delete a component."""
+    config = self._services.config.GetProjectConfig(mar.cnxn, mar.project_id)
+    component_path = request.componentPath
+    component_def = tracker_bizobj.FindComponentDef(
+        component_path, config)
+    if not component_def:
+      raise exceptions.NoSuchComponentException(
+          'The component %s does not exist.' % component_path)
+    if not permissions.CanViewComponentDef(
+        mar.auth.effective_ids, mar.perms, mar.project, component_def):
+      raise permissions.PermissionException(
+          'User is not allowed to view this component %s' % component_path)
+    if not permissions.CanEditComponentDef(
+        mar.auth.effective_ids, mar.perms, mar.project, component_def, config):
+      raise permissions.PermissionException(
+          'User is not allowed to delete this component %s' % component_path)
+
+    allow_delete = not tracker_bizobj.FindDescendantComponents(
+        config, component_def)
+    if not allow_delete:
+      raise permissions.PermissionException(
+          'User tried to delete component that had subcomponents')
+
+    self._services.issue.DeleteComponentReferences(
+        mar.cnxn, component_def.component_id)
+    self._services.config.DeleteComponentDef(
+        mar.cnxn, mar.project_id, component_def.component_id)
+    return message_types.VoidMessage()
+
+  @monorail_api_method(
+      api_pb2_v1.COMPONENTS_UPDATE_REQUEST_RESOURCE_CONTAINER,
+      message_types.VoidMessage,
+      path='projects/{projectId}/components/{componentPath}',
+      http_method='POST',
+      name='components.update')
+  def components_update(self, mar, request):
+    """Update a component."""
+    config = self._services.config.GetProjectConfig(mar.cnxn, mar.project_id)
+    component_path = request.componentPath
+    component_def = tracker_bizobj.FindComponentDef(
+        component_path, config)
+    if not component_def:
+      raise exceptions.NoSuchComponentException(
+          'The component %s does not exist.' % component_path)
+    if not permissions.CanViewComponentDef(
+        mar.auth.effective_ids, mar.perms, mar.project, component_def):
+      raise permissions.PermissionException(
+          'User is not allowed to view this component %s' % component_path)
+    if not permissions.CanEditComponentDef(
+        mar.auth.effective_ids, mar.perms, mar.project, component_def, config):
+      raise permissions.PermissionException(
+          'User is not allowed to edit this component %s' % component_path)
+
+    original_path = component_def.path
+    new_path = component_def.path
+    new_docstring = component_def.docstring
+    new_deprecated = component_def.deprecated
+    new_admin_ids = component_def.admin_ids
+    new_cc_ids = component_def.cc_ids
+    update_filterrule = False
+    for update in request.updates:
+      if update.field == api_pb2_v1.ComponentUpdateFieldID.LEAF_NAME:
+        leaf_name = update.leafName
+        if not tracker_constants.COMPONENT_NAME_RE.match(leaf_name):
+          raise exceptions.InvalidComponentNameException(
+              'The component name %s is invalid.' % leaf_name)
+
+        if '>' in original_path:
+          parent_path = original_path[:original_path.rindex('>')]
+          new_path = '%s>%s' % (parent_path, leaf_name)
+        else:
+          new_path = leaf_name
+
+        conflict = tracker_bizobj.FindComponentDef(new_path, config)
+        if conflict and conflict.component_id != component_def.component_id:
+          raise exceptions.InvalidComponentNameException(
+              'The name %s is already in use.' % new_path)
+        update_filterrule = True
+      elif update.field == api_pb2_v1.ComponentUpdateFieldID.DESCRIPTION:
+        new_docstring = update.description
+      elif update.field == api_pb2_v1.ComponentUpdateFieldID.ADMIN:
+        user_ids_dict = self._services.user.LookupUserIDs(
+            mar.cnxn, list(update.admin), autocreate=True)
+        new_admin_ids = list(set(user_ids_dict.values()))
+      elif update.field == api_pb2_v1.ComponentUpdateFieldID.CC:
+        user_ids_dict = self._services.user.LookupUserIDs(
+            mar.cnxn, list(update.cc), autocreate=True)
+        new_cc_ids = list(set(user_ids_dict.values()))
+        update_filterrule = True
+      elif update.field == api_pb2_v1.ComponentUpdateFieldID.DEPRECATED:
+        new_deprecated = update.deprecated
+      else:
+        logging.error('Unknown component field %r', update.field)
+
+    new_modified = int(time.time())
+    new_modifier_id = self._services.user.LookupUserID(
+        mar.cnxn, mar.auth.email, autocreate=False)
+    logging.info(
+        'Updating component id %d: path-%s, docstring-%s, deprecated-%s,'
+        ' admin_ids-%s, cc_ids-%s modified by %s', component_def.component_id,
+        new_path, new_docstring, new_deprecated, new_admin_ids, new_cc_ids,
+        new_modifier_id)
+    self._services.config.UpdateComponentDef(
+        mar.cnxn, mar.project_id, component_def.component_id,
+        path=new_path, docstring=new_docstring, deprecated=new_deprecated,
+        admin_ids=new_admin_ids, cc_ids=new_cc_ids, modified=new_modified,
+        modifier_id=new_modifier_id)
+
+    # TODO(sheyang): reuse the code in componentdetails
+    if original_path != new_path:
+      # If the name changed then update all of its subcomponents as well.
+      subcomponent_ids = tracker_bizobj.FindMatchingComponentIDs(
+          original_path, config, exact=False)
+      for subcomponent_id in subcomponent_ids:
+        if subcomponent_id == component_def.component_id:
+          continue
+        subcomponent_def = tracker_bizobj.FindComponentDefByID(
+            subcomponent_id, config)
+        subcomponent_new_path = subcomponent_def.path.replace(
+            original_path, new_path, 1)
+        self._services.config.UpdateComponentDef(
+            mar.cnxn, mar.project_id, subcomponent_def.component_id,
+            path=subcomponent_new_path)
+
+    if update_filterrule:
+      filterrules_helpers.RecomputeAllDerivedFields(
+          mar.cnxn, self._services, mar.project, config)
+
+    return message_types.VoidMessage()
+
+
+@endpoints.api(name='monorail_client_configs', version='v1',
+               description='Monorail API client configs.')
+class ClientConfigApi(remote.Service):
+
+  # Class variables. Handy to mock.
+  _services = None
+  _mar = None
+
+  @classmethod
+  def _set_services(cls, services):
+    cls._services = services
+
+  def mar_factory(self, request, cnxn):
+    if not self._mar:
+      self._mar = monorailrequest.MonorailApiRequest(
+          request, self._services, cnxn=cnxn)
+    return self._mar
+
+  @endpoints.method(
+      message_types.VoidMessage,
+      message_types.VoidMessage,
+      path='client_configs',
+      http_method='POST',
+      name='client_configs.update')
+  def client_configs_update(self, request):
+    if self._services is None:
+      self._set_services(service_manager.set_up_services())
+    mar = self.mar_factory(request, sql.MonorailConnection())
+    if not mar.perms.HasPerm(permissions.ADMINISTER_SITE, None, None):
+      raise permissions.PermissionException(
+          'The requester %s is not allowed to update client configs.' %
+           mar.auth.email)
+
+    ROLE_DICT = {
+        1: permissions.COMMITTER_ROLE,
+        2: permissions.CONTRIBUTOR_ROLE,
+    }
+
+    client_config = client_config_svc.GetClientConfigSvc()
+
+    cfg = client_config.GetConfigs()
+    if not cfg:
+      msg = 'Failed to fetch client configs.'
+      logging.error(msg)
+      raise endpoints.InternalServerErrorException(msg)
+
+    for client in cfg.clients:
+      if not client.client_email:
+        continue
+      # 1: create the user if non-existent
+      user_id = self._services.user.LookupUserID(
+          mar.cnxn, client.client_email, autocreate=True)
+      user_pb = self._services.user.GetUser(mar.cnxn, user_id)
+
+      logging.info('User ID %d for email %s', user_id, client.client_email)
+
+      # 2: set period and lifetime limit
+      # new_soft_limit, new_hard_limit, new_lifetime_limit
+      new_limit_tuple = (
+          client.period_limit, client.period_limit, client.lifetime_limit)
+      action_limit_updates = {'api_request': new_limit_tuple}
+      self._services.user.UpdateUserSettings(
+          mar.cnxn, user_id, user_pb, action_limit_updates=action_limit_updates)
+
+      logging.info('Updated api request limit %r', new_limit_tuple)
+
+      # 3: Update project role and extra perms
+      projects_dict = self._services.project.GetAllProjects(mar.cnxn)
+      project_name_to_ids = {
+          p.project_name: p.project_id for p in projects_dict.values()}
+
+      # Set project role and extra perms
+      for perm in client.project_permissions:
+        project_ids = self._GetProjectIDs(perm.project, project_name_to_ids)
+        logging.info('Matching projects %r for name %s',
+                     project_ids, perm.project)
+
+        role = ROLE_DICT[perm.role]
+        for p_id in project_ids:
+          project = projects_dict[p_id]
+          people_list = []
+          if role == 'owner':
+            people_list = project.owner_ids
+          elif role == 'committer':
+            people_list = project.committer_ids
+          elif role == 'contributor':
+            people_list = project.contributor_ids
+          # Onlu update role/extra perms iff changed
+          if not user_id in people_list:
+            logging.info('Update project %s role %s for user %s',
+                         project.project_name, role, client.client_email)
+            owner_ids, committer_ids, contributor_ids = (
+                project_helpers.MembersWithGivenIDs(project, {user_id}, role))
+            self._services.project.UpdateProjectRoles(
+                mar.cnxn, p_id, owner_ids, committer_ids,
+                contributor_ids)
+          if perm.extra_permissions:
+            logging.info('Update project %s extra perm %s for user %s',
+                         project.project_name, perm.extra_permissions,
+                         client.client_email)
+            self._services.project.UpdateExtraPerms(
+                mar.cnxn, p_id, user_id, list(perm.extra_permissions))
+
+    mar.CleanUp()
+    return message_types.VoidMessage()
+
+  def _GetProjectIDs(self, project_str, project_name_to_ids):
+    result = []
+    if any(ch in project_str for ch in ['*', '+', '?', '.']):
+      pattern = re.compile(project_str)
+      for p_name in project_name_to_ids.keys():
+        if pattern.match(p_name):
+          project_id = project_name_to_ids.get(p_name)
+          if project_id:
+            result.append(project_id)
+    else:
+      project_id = project_name_to_ids.get(project_str)
+      if project_id:
+        result.append(project_id)
+
+    if not result:
+      logging.warning('Cannot find projects for specified name %s',
+                      project_str)
+    return result