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)