| # 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) |