Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/api/v3/issues_servicer.py b/api/v3/issues_servicer.py
new file mode 100644
index 0000000..ebd545b
--- /dev/null
+++ b/api/v3/issues_servicer.py
@@ -0,0 +1,396 @@
+# Copyright 2020 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
+
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+
+import re
+
+from api import resource_name_converters as rnc
+from api.v3 import api_constants
+from api.v3 import converters
+from api.v3 import monorail_servicer
+from api.v3 import paginator
+from api.v3.api_proto import issues_pb2
+from api.v3.api_proto import issue_objects_pb2
+from api.v3.api_proto import issues_prpc_pb2
+from businesslogic import work_env
+from framework import exceptions
+
+# We accept only the following filter, and only on ListComments.
+# If we accept more complex filters in the future, introduce a library.
+_APPROVAL_DEF_FILTER_RE = re.compile(
+    r'approval = "(?P<approval_name>%s)"$' % rnc.APPROVAL_DEF_NAME_PATTERN)
+
+
+class IssuesServicer(monorail_servicer.MonorailServicer):
+  """Handle API requests related to Issue objects.
+  Each API request is implemented with a method as defined in the
+  .proto file that does any request-specific validation, uses work_env
+  to safely operate on business objects, and returns a response proto.
+  """
+
+  DESCRIPTION = issues_prpc_pb2.IssuesServiceDescription
+
+  @monorail_servicer.PRPCMethod
+  def GetIssue(self, mc, request):
+    # type: (MonorailContext, GetIssueRequest) -> Issue
+    """pRPC API method that implements GetIssue.
+
+    Raises:
+      InputException: the given name does not have a valid format.
+      NoSuchIssueException: the issue is not found.
+      PermissionException the user is not allowed to view the issue.
+    """
+    issue_id = rnc.IngestIssueName(mc.cnxn, request.name, self.services)
+    with work_env.WorkEnv(mc, self.services) as we:
+      # TODO(crbug/monorail/7614): Eliminate the need to do this lookup.
+      project = we.GetProjectByName(rnc.IngestProjectFromIssue(request.name))
+      mc.LookupLoggedInUserPerms(project)
+      issue = we.GetIssue(issue_id, allow_viewing_deleted=True)
+    return self.converter.ConvertIssue(issue)
+
+  @monorail_servicer.PRPCMethod
+  def BatchGetIssues(self, mc, request):
+    # type: (MonorailContext, BatchGetIssuesRequest) -> BatchGetIssuesResponse
+    """pRPC API method that implements BatchGetIssues.
+
+    Raises:
+      InputException: If `names` is formatted incorrectly. Or if a parent
+          collection in `names` does not match the value in `parent`.
+      NoSuchIssueException: If any of the given issues do not exist.
+      PermissionException If the requester does not have permission to view one
+          (or more) of the given issues.
+    """
+    if len(request.names) > api_constants.MAX_BATCH_ISSUES:
+      raise exceptions.InputException(
+          'Requesting %d issues when the allowed maximum is %d issues.' %
+          (len(request.names), api_constants.MAX_BATCH_ISSUES))
+    if request.parent:
+      parent_match = rnc._GetResourceNameMatch(
+          request.parent, rnc.PROJECT_NAME_RE)
+      parent_project = parent_match.group('project_name')
+      with exceptions.ErrorAggregator(exceptions.InputException) as err_agg:
+        for name in request.names:
+          try:
+            name_match = rnc._GetResourceNameMatch(name, rnc.ISSUE_NAME_RE)
+            issue_project = name_match.group('project')
+            if issue_project != parent_project:
+              err_agg.AddErrorMessage(
+                  '%s is not a child issue of %s.' % (name, request.parent))
+          except exceptions.InputException as e:
+            err_agg.AddErrorMessage(e.message)
+    with work_env.WorkEnv(mc, self.services) as we:
+      # NOTE(crbug/monorail/7614): Until the referenced cleanup is complete,
+      # all servicer methods that are scoped to a single Project need to call
+      # mc.LookupLoggedInUserPerms.
+      #  This method does not because it may be scoped to multiple projects.
+      issue_ids = rnc.IngestIssueNames(mc.cnxn, request.names, self.services)
+      issues_by_iid = we.GetIssuesDict(issue_ids)
+    return issues_pb2.BatchGetIssuesResponse(
+        issues=self.converter.ConvertIssues(
+            [issues_by_iid[issue_id] for issue_id in issue_ids]))
+
+  @monorail_servicer.PRPCMethod
+  def SearchIssues(self, mc, request):
+    # type: (MonorailContext, SearchIssuesRequest) -> SearchIssuesResponse
+    """pRPC API method that implements SearchIssue.
+
+    Raises:
+      InputException: if any given names in `projects` are invalid or if the
+        search query uses invalid syntax (ie: unmatched parentheses).
+    """
+    page_size = paginator.CoercePageSize(
+        request.page_size, api_constants.MAX_ISSUES_PER_PAGE)
+    pager = paginator.Paginator(
+        page_size=page_size,
+        order_by=request.order_by,
+        query=request.query,
+        projects=request.projects)
+
+    project_names = []
+    for resource_name in request.projects:
+      match = rnc._GetResourceNameMatch(resource_name, rnc.PROJECT_NAME_RE)
+      project_names.append(match.group('project_name'))
+
+    with work_env.WorkEnv(mc, self.services) as we:
+      # NOTE(crbug/monorail/7614): Until the referenced cleanup is complete,
+      # all servicer methods that are scoped to a single Project need to call
+      # mc.LookupLoggedInUserPerms.
+      #  This method does not because it may be scoped to multiple projects.
+      list_result = we.SearchIssues(
+          request.query, project_names, mc.auth.user_id, page_size,
+          pager.GetStart(request.page_token), request.order_by)
+
+    return issues_pb2.SearchIssuesResponse(
+        issues=self.converter.ConvertIssues(list_result.items),
+        next_page_token=pager.GenerateNextPageToken(list_result.next_start))
+
+  @monorail_servicer.PRPCMethod
+  def ListComments(self, mc, request):
+    # type: (MonorailContext, ListCommentsRequest) -> ListCommentsResponse
+    """pRPC API method that implements ListComments.
+
+    Raises:
+      InputException: the given name format or page_size are not valid.
+      NoSuchIssueException: the parent is not found.
+      PermissionException: the user is not allowed to view the parent.
+    """
+    issue_id = rnc.IngestIssueName(mc.cnxn, request.parent, self.services)
+    page_size = paginator.CoercePageSize(
+        request.page_size, api_constants.MAX_COMMENTS_PER_PAGE)
+    pager = paginator.Paginator(
+      parent=request.parent, page_size=page_size, filter_str=request.filter)
+    approval_id = None
+    if request.filter:
+      match = _APPROVAL_DEF_FILTER_RE.match(request.filter)
+      if match:
+        approval_id = rnc.IngestApprovalDefName(
+            mc.cnxn, match.group('approval_name'), self.services)
+      if not match:
+        raise exceptions.InputException(
+            'Filtering other than approval not supported.')
+
+    with work_env.WorkEnv(mc, self.services) as we:
+      # TODO(crbug/monorail/7614): Eliminate the need to do this lookup.
+      project = we.GetProjectByName(rnc.IngestProjectFromIssue(request.parent))
+      mc.LookupLoggedInUserPerms(project)
+      list_result = we.SafeListIssueComments(
+          issue_id, page_size, pager.GetStart(request.page_token),
+          approval_id=approval_id)
+    return issues_pb2.ListCommentsResponse(
+        comments=self.converter.ConvertComments(issue_id, list_result.items),
+        next_page_token=pager.GenerateNextPageToken(list_result.next_start))
+
+  @monorail_servicer.PRPCMethod
+  def ListApprovalValues(self, mc, request):
+    # type: (MonorailContext, ListApprovalValuesRequest) ->
+    #     ListApprovalValuesResponse
+    """pRPC API method that implements ListApprovalValues.
+
+    Raises:
+      InputException: the given parent does not have a valid format.
+      NoSuchIssueException: the parent issue is not found.
+      PermissionException the user is not allowed to view the parent issue.
+    """
+    issue_id = rnc.IngestIssueName(mc.cnxn, request.parent, self.services)
+    with work_env.WorkEnv(mc, self.services) as we:
+      # TODO(crbug/monorail/7614): Eliminate the need to do this lookup.
+      project = we.GetProjectByName(rnc.IngestProjectFromIssue(request.parent))
+      mc.LookupLoggedInUserPerms(project)
+      issue = we.GetIssue(issue_id)
+
+    api_avs = self.converter.ConvertApprovalValues(issue.approval_values,
+        issue.field_values, issue.phases, issue_id=issue_id)
+
+    return issues_pb2.ListApprovalValuesResponse(approval_values=api_avs)
+
+  @monorail_servicer.PRPCMethod
+  def MakeIssueFromTemplate(self, _mc, _request):
+    # type: (MonorailContext, MakeIssueFromTemplateRequest) -> Issue
+    """pRPC API method that implements MakeIssueFromTemplate.
+
+    Raises:
+      TODO(crbug/monorail/7197): Document errors when implemented
+    """
+    # Phase 1: Gather info
+    #   Get project id and template name from template resource name.
+    #   Get template pb.
+    #   Make tracker_pb2.IssueDelta from request.template_issue_delta, share
+    #   code with v3/ModifyIssue
+
+    # with work_env.WorkEnv(mc, self.services) as we:
+    #   project = ... get project from template.
+    #   mc.LookupLoggedInUserPerms(project)
+    #   created_issue = we.MakeIssueFromTemplate(template, description, delta)
+
+    # Return newly created API issue.
+    # return converters.ConvertIssue(created_issue)
+
+    return issue_objects_pb2.Issue()
+
+  @monorail_servicer.PRPCMethod
+  def MakeIssue(self, mc, request):
+    # type: (MonorailContext, MakeIssueRequest) -> Issue
+    """pRPC API method that implements MakeIssue.
+
+    Raises:
+      InputException if any given names do not have a valid format or if any
+        fields in the requested issue were invalid.
+      NoSuchProjectException if no project exists with the given parent.
+      FilterRuleException if proposed issue values violate any filter rules
+        that shows error.
+      PermissionException if user lacks sufficient permissions.
+    """
+    project_id = rnc.IngestProjectName(mc.cnxn, request.parent, self.services)
+    with work_env.WorkEnv(mc, self.services) as we:
+      # TODO(crbug/monorail/7614): Eliminate the need to do this lookup.
+      project = we.GetProject(project_id)
+      mc.LookupLoggedInUserPerms(project)
+
+    ingested_issue = self.converter.IngestIssue(
+        request.issue, project_id)
+    send_email = self.converter.IngestNotifyType(request.notify_type)
+
+    with work_env.WorkEnv(mc, self.services) as we:
+      created_issue = we.MakeIssue(
+          ingested_issue, request.description, send_email)
+      starred_issue = we.StarIssue(created_issue, True)
+
+    return self.converter.ConvertIssue(starred_issue)
+
+  @monorail_servicer.PRPCMethod
+  def ModifyIssues(self, mc, request):
+    # type: (MonorailContext, ModifyIssuesRequest) -> ModifyIssuesResponse
+    """pRPC API method that implements ModifyIssues.
+
+    Raises:
+      InputException if any given names do not have a valid format or if any
+        fields in the requested issue were invalid.
+      NoSuchIssueException if some issues weren't found.
+      NoSuchProjectException if no project was found for some given issues.
+      FilterRuleException if proposed issue changes violate any filter rules
+        that shows error.
+      PermissionException if user lacks sufficient permissions.
+    """
+    if not request.deltas:
+      return issues_pb2.ModifyIssuesResponse()
+    if len(request.deltas) > api_constants.MAX_MODIFY_ISSUES:
+      raise exceptions.InputException(
+          'Requesting %d updates when the allowed maximum is %d updates.' %
+          (len(request.deltas), api_constants.MAX_MODIFY_ISSUES))
+    impacted_issues_count = 0
+    for delta in request.deltas:
+      impacted_issues_count += (
+          len(delta.blocked_on_issues_remove) +
+          len(delta.blocking_issues_remove) +
+          len(delta.issue.blocking_issue_refs) +
+          len(delta.issue.blocked_on_issue_refs))
+      if 'merged_into_issue_ref' in delta.update_mask.paths:
+        impacted_issues_count += 1
+    if impacted_issues_count > api_constants.MAX_MODIFY_IMPACTED_ISSUES:
+      raise exceptions.InputException(
+          'Updates include %d impacted issues when the allowed maximum is %d.' %
+          (impacted_issues_count, api_constants.MAX_MODIFY_IMPACTED_ISSUES))
+    iid_delta_pairs = self.converter.IngestIssueDeltas(request.deltas)
+    with work_env.WorkEnv(mc, self.services) as we:
+      issues = we.ModifyIssues(
+          iid_delta_pairs,
+          attachment_uploads=self.converter.IngestAttachmentUploads(
+              request.uploads),
+          comment_content=request.comment_content,
+          send_email=self.converter.IngestNotifyType(request.notify_type))
+
+    return issues_pb2.ModifyIssuesResponse(
+        issues=self.converter.ConvertIssues(issues))
+
+  @monorail_servicer.PRPCMethod
+  def ModifyIssueApprovalValues(self, mc, request):
+    # type: (MonorailContext, ModifyIssueApprovalValuesRequest) ->
+    #     ModifyIssueApprovalValuesResponse
+    """pRPC API method that implements ModifyIssueApprovalValues.
+
+    Raises:
+      InputException if any fields in the delta were invalid.
+      NoSuchIssueException: if the issue of any ApprovalValue isn't found.
+      NoSuchProjectException: if the parent project of any ApprovalValue isn't
+          found.
+      NoSuchUserException: if any user value provided isn't found.
+      PermissionException if user lacks sufficient permissions.
+      # TODO(crbug/monorail/7925): Not all of these are yet thrown.
+    """
+    if len(request.deltas) > api_constants.MAX_MODIFY_APPROVAL_VALUES:
+      raise exceptions.InputException(
+          'Requesting %d updates when the allowed maximum is %d updates.' %
+          (len(request.deltas), api_constants.MAX_MODIFY_APPROVAL_VALUES))
+    response = issues_pb2.ModifyIssueApprovalValuesResponse()
+    delta_specifications = self.converter.IngestApprovalDeltas(
+        request.deltas, mc.auth.user_id)
+    send_email = self.converter.IngestNotifyType(request.notify_type)
+    with work_env.WorkEnv(mc, self.services) as we:
+      # NOTE(crbug/monorail/7614): Until the referenced cleanup is complete,
+      # all servicer methods that are scoped to a single Project need to call
+      # mc.LookupLoggedInUserPerms.
+      # This method does not because it may be scoped to multiple projects.
+      issue_approval_values = we.BulkUpdateIssueApprovalsV3(
+          delta_specifications, request.comment_content, send_email=send_email)
+    api_avs = []
+    for issue, approval_value in issue_approval_values:
+      api_avs.extend(
+          self.converter.ConvertApprovalValues(
+              [approval_value],
+              issue.field_values,
+              issue.phases,
+              issue_id=issue.issue_id))
+    response.approval_values.extend(api_avs)
+    return response
+
+  @monorail_servicer.PRPCMethod
+  def ModifyCommentState(self, mc, request):
+    # type: (MonorailContext, ModifyCommentStateRequest) ->
+    #     ModifyCommentStateResponse
+    """pRPC API method that implements ModifyCommentState.
+
+    We do not support changing between DELETED <-> SPAM. User must
+    undelete or unflag-as-spam first.
+
+    Raises:
+      NoSuchProjectException if the parent Project does not exist.
+      NoSuchIssueException: if the issue does not exist.
+      NoSuchCommentException: if the comment does not exist.
+      PermissionException if user lacks sufficient permissions.
+      ActionNotSupported if user requests unsupported state transitions.
+    """
+    (project_id, issue_id,
+     comment_num) = rnc.IngestCommentName(mc.cnxn, request.name, self.services)
+    with work_env.WorkEnv(mc, self.services) as we:
+      # TODO(crbug/monorail/7614): Eliminate the need to do this lookup.
+      project = we.GetProject(project_id)
+      mc.LookupLoggedInUserPerms(project)
+      issue = we.GetIssue(issue_id, use_cache=False)
+      comments_list = we.SafeListIssueComments(issue_id, 1, comment_num).items
+      try:
+        comment = comments_list[0]
+      except IndexError:
+        raise exceptions.NoSuchCommentException()
+
+      if request.state == issue_objects_pb2.IssueContentState.Value('ACTIVE'):
+        if comment.is_spam:
+          we.FlagComment(issue, comment, False)
+        elif comment.deleted_by != 0:
+          we.DeleteComment(issue, comment, delete=False)
+        else:
+          # No-op if already currently active
+          pass
+      elif request.state == issue_objects_pb2.IssueContentState.Value(
+          'DELETED'):
+        if (not comment.deleted_by) and (not comment.is_spam):
+          we.DeleteComment(issue, comment, delete=True)
+        elif comment.deleted_by and not comment.is_spam:
+          # No-op if already deleted
+          pass
+        else:
+          raise exceptions.ActionNotSupported(
+              'Cannot change comment state from spam to deleted.')
+      elif request.state == issue_objects_pb2.IssueContentState.Value('SPAM'):
+        if (not comment.deleted_by) and (not comment.is_spam):
+          we.FlagComment(issue, comment, True)
+        elif comment.is_spam:
+          # No-op if already spam
+          pass
+        else:
+          raise exceptions.ActionNotSupported(
+              'Cannot change comment state from deleted to spam.')
+      else:
+        raise exceptions.ActionNotSupported('Unsupported target comment state.')
+
+      # FlagComment does not have side effect on comment, must refresh.
+      refreshed_comment = we.SafeListIssueComments(issue_id, 1,
+                                                   comment_num).items[0]
+
+    converted_comment = self.converter.ConvertComments(
+        issue_id, [refreshed_comment])[0]
+    return issues_pb2.ModifyCommentStateResponse(comment=converted_comment)