blob: e5ff08712d8776ec69964e9af32241f90923d2ba [file] [log] [blame]
# 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)
ingested_attachments = self.converter.IngestAttachmentUploads(
request.uploads)
with work_env.WorkEnv(mc, self.services) as we:
created_issue = we.MakeIssue(
ingested_issue,
request.description,
send_email,
attachment_uploads=ingested_attachments)
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)