blob: ebd545b4e5b0f0155f19fb7a462c823801cc5516 [file] [log] [blame]
Copybara854996b2021-09-07 19:36:02 +00001# Copyright 2020 The Chromium Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style
3# license that can be found in the LICENSE file or at
4# https://developers.google.com/open-source/licenses/bsd
5
6from __future__ import print_function
7from __future__ import division
8from __future__ import absolute_import
9
10import re
11
12from api import resource_name_converters as rnc
13from api.v3 import api_constants
14from api.v3 import converters
15from api.v3 import monorail_servicer
16from api.v3 import paginator
17from api.v3.api_proto import issues_pb2
18from api.v3.api_proto import issue_objects_pb2
19from api.v3.api_proto import issues_prpc_pb2
20from businesslogic import work_env
21from framework import exceptions
22
23# We accept only the following filter, and only on ListComments.
24# If we accept more complex filters in the future, introduce a library.
25_APPROVAL_DEF_FILTER_RE = re.compile(
26 r'approval = "(?P<approval_name>%s)"$' % rnc.APPROVAL_DEF_NAME_PATTERN)
27
28
29class IssuesServicer(monorail_servicer.MonorailServicer):
30 """Handle API requests related to Issue objects.
31 Each API request is implemented with a method as defined in the
32 .proto file that does any request-specific validation, uses work_env
33 to safely operate on business objects, and returns a response proto.
34 """
35
36 DESCRIPTION = issues_prpc_pb2.IssuesServiceDescription
37
38 @monorail_servicer.PRPCMethod
39 def GetIssue(self, mc, request):
40 # type: (MonorailContext, GetIssueRequest) -> Issue
41 """pRPC API method that implements GetIssue.
42
43 Raises:
44 InputException: the given name does not have a valid format.
45 NoSuchIssueException: the issue is not found.
46 PermissionException the user is not allowed to view the issue.
47 """
48 issue_id = rnc.IngestIssueName(mc.cnxn, request.name, self.services)
49 with work_env.WorkEnv(mc, self.services) as we:
50 # TODO(crbug/monorail/7614): Eliminate the need to do this lookup.
51 project = we.GetProjectByName(rnc.IngestProjectFromIssue(request.name))
52 mc.LookupLoggedInUserPerms(project)
53 issue = we.GetIssue(issue_id, allow_viewing_deleted=True)
54 return self.converter.ConvertIssue(issue)
55
56 @monorail_servicer.PRPCMethod
57 def BatchGetIssues(self, mc, request):
58 # type: (MonorailContext, BatchGetIssuesRequest) -> BatchGetIssuesResponse
59 """pRPC API method that implements BatchGetIssues.
60
61 Raises:
62 InputException: If `names` is formatted incorrectly. Or if a parent
63 collection in `names` does not match the value in `parent`.
64 NoSuchIssueException: If any of the given issues do not exist.
65 PermissionException If the requester does not have permission to view one
66 (or more) of the given issues.
67 """
68 if len(request.names) > api_constants.MAX_BATCH_ISSUES:
69 raise exceptions.InputException(
70 'Requesting %d issues when the allowed maximum is %d issues.' %
71 (len(request.names), api_constants.MAX_BATCH_ISSUES))
72 if request.parent:
73 parent_match = rnc._GetResourceNameMatch(
74 request.parent, rnc.PROJECT_NAME_RE)
75 parent_project = parent_match.group('project_name')
76 with exceptions.ErrorAggregator(exceptions.InputException) as err_agg:
77 for name in request.names:
78 try:
79 name_match = rnc._GetResourceNameMatch(name, rnc.ISSUE_NAME_RE)
80 issue_project = name_match.group('project')
81 if issue_project != parent_project:
82 err_agg.AddErrorMessage(
83 '%s is not a child issue of %s.' % (name, request.parent))
84 except exceptions.InputException as e:
85 err_agg.AddErrorMessage(e.message)
86 with work_env.WorkEnv(mc, self.services) as we:
87 # NOTE(crbug/monorail/7614): Until the referenced cleanup is complete,
88 # all servicer methods that are scoped to a single Project need to call
89 # mc.LookupLoggedInUserPerms.
90 # This method does not because it may be scoped to multiple projects.
91 issue_ids = rnc.IngestIssueNames(mc.cnxn, request.names, self.services)
92 issues_by_iid = we.GetIssuesDict(issue_ids)
93 return issues_pb2.BatchGetIssuesResponse(
94 issues=self.converter.ConvertIssues(
95 [issues_by_iid[issue_id] for issue_id in issue_ids]))
96
97 @monorail_servicer.PRPCMethod
98 def SearchIssues(self, mc, request):
99 # type: (MonorailContext, SearchIssuesRequest) -> SearchIssuesResponse
100 """pRPC API method that implements SearchIssue.
101
102 Raises:
103 InputException: if any given names in `projects` are invalid or if the
104 search query uses invalid syntax (ie: unmatched parentheses).
105 """
106 page_size = paginator.CoercePageSize(
107 request.page_size, api_constants.MAX_ISSUES_PER_PAGE)
108 pager = paginator.Paginator(
109 page_size=page_size,
110 order_by=request.order_by,
111 query=request.query,
112 projects=request.projects)
113
114 project_names = []
115 for resource_name in request.projects:
116 match = rnc._GetResourceNameMatch(resource_name, rnc.PROJECT_NAME_RE)
117 project_names.append(match.group('project_name'))
118
119 with work_env.WorkEnv(mc, self.services) as we:
120 # NOTE(crbug/monorail/7614): Until the referenced cleanup is complete,
121 # all servicer methods that are scoped to a single Project need to call
122 # mc.LookupLoggedInUserPerms.
123 # This method does not because it may be scoped to multiple projects.
124 list_result = we.SearchIssues(
125 request.query, project_names, mc.auth.user_id, page_size,
126 pager.GetStart(request.page_token), request.order_by)
127
128 return issues_pb2.SearchIssuesResponse(
129 issues=self.converter.ConvertIssues(list_result.items),
130 next_page_token=pager.GenerateNextPageToken(list_result.next_start))
131
132 @monorail_servicer.PRPCMethod
133 def ListComments(self, mc, request):
134 # type: (MonorailContext, ListCommentsRequest) -> ListCommentsResponse
135 """pRPC API method that implements ListComments.
136
137 Raises:
138 InputException: the given name format or page_size are not valid.
139 NoSuchIssueException: the parent is not found.
140 PermissionException: the user is not allowed to view the parent.
141 """
142 issue_id = rnc.IngestIssueName(mc.cnxn, request.parent, self.services)
143 page_size = paginator.CoercePageSize(
144 request.page_size, api_constants.MAX_COMMENTS_PER_PAGE)
145 pager = paginator.Paginator(
146 parent=request.parent, page_size=page_size, filter_str=request.filter)
147 approval_id = None
148 if request.filter:
149 match = _APPROVAL_DEF_FILTER_RE.match(request.filter)
150 if match:
151 approval_id = rnc.IngestApprovalDefName(
152 mc.cnxn, match.group('approval_name'), self.services)
153 if not match:
154 raise exceptions.InputException(
155 'Filtering other than approval not supported.')
156
157 with work_env.WorkEnv(mc, self.services) as we:
158 # TODO(crbug/monorail/7614): Eliminate the need to do this lookup.
159 project = we.GetProjectByName(rnc.IngestProjectFromIssue(request.parent))
160 mc.LookupLoggedInUserPerms(project)
161 list_result = we.SafeListIssueComments(
162 issue_id, page_size, pager.GetStart(request.page_token),
163 approval_id=approval_id)
164 return issues_pb2.ListCommentsResponse(
165 comments=self.converter.ConvertComments(issue_id, list_result.items),
166 next_page_token=pager.GenerateNextPageToken(list_result.next_start))
167
168 @monorail_servicer.PRPCMethod
169 def ListApprovalValues(self, mc, request):
170 # type: (MonorailContext, ListApprovalValuesRequest) ->
171 # ListApprovalValuesResponse
172 """pRPC API method that implements ListApprovalValues.
173
174 Raises:
175 InputException: the given parent does not have a valid format.
176 NoSuchIssueException: the parent issue is not found.
177 PermissionException the user is not allowed to view the parent issue.
178 """
179 issue_id = rnc.IngestIssueName(mc.cnxn, request.parent, self.services)
180 with work_env.WorkEnv(mc, self.services) as we:
181 # TODO(crbug/monorail/7614): Eliminate the need to do this lookup.
182 project = we.GetProjectByName(rnc.IngestProjectFromIssue(request.parent))
183 mc.LookupLoggedInUserPerms(project)
184 issue = we.GetIssue(issue_id)
185
186 api_avs = self.converter.ConvertApprovalValues(issue.approval_values,
187 issue.field_values, issue.phases, issue_id=issue_id)
188
189 return issues_pb2.ListApprovalValuesResponse(approval_values=api_avs)
190
191 @monorail_servicer.PRPCMethod
192 def MakeIssueFromTemplate(self, _mc, _request):
193 # type: (MonorailContext, MakeIssueFromTemplateRequest) -> Issue
194 """pRPC API method that implements MakeIssueFromTemplate.
195
196 Raises:
197 TODO(crbug/monorail/7197): Document errors when implemented
198 """
199 # Phase 1: Gather info
200 # Get project id and template name from template resource name.
201 # Get template pb.
202 # Make tracker_pb2.IssueDelta from request.template_issue_delta, share
203 # code with v3/ModifyIssue
204
205 # with work_env.WorkEnv(mc, self.services) as we:
206 # project = ... get project from template.
207 # mc.LookupLoggedInUserPerms(project)
208 # created_issue = we.MakeIssueFromTemplate(template, description, delta)
209
210 # Return newly created API issue.
211 # return converters.ConvertIssue(created_issue)
212
213 return issue_objects_pb2.Issue()
214
215 @monorail_servicer.PRPCMethod
216 def MakeIssue(self, mc, request):
217 # type: (MonorailContext, MakeIssueRequest) -> Issue
218 """pRPC API method that implements MakeIssue.
219
220 Raises:
221 InputException if any given names do not have a valid format or if any
222 fields in the requested issue were invalid.
223 NoSuchProjectException if no project exists with the given parent.
224 FilterRuleException if proposed issue values violate any filter rules
225 that shows error.
226 PermissionException if user lacks sufficient permissions.
227 """
228 project_id = rnc.IngestProjectName(mc.cnxn, request.parent, self.services)
229 with work_env.WorkEnv(mc, self.services) as we:
230 # TODO(crbug/monorail/7614): Eliminate the need to do this lookup.
231 project = we.GetProject(project_id)
232 mc.LookupLoggedInUserPerms(project)
233
234 ingested_issue = self.converter.IngestIssue(
235 request.issue, project_id)
236 send_email = self.converter.IngestNotifyType(request.notify_type)
237
238 with work_env.WorkEnv(mc, self.services) as we:
239 created_issue = we.MakeIssue(
240 ingested_issue, request.description, send_email)
241 starred_issue = we.StarIssue(created_issue, True)
242
243 return self.converter.ConvertIssue(starred_issue)
244
245 @monorail_servicer.PRPCMethod
246 def ModifyIssues(self, mc, request):
247 # type: (MonorailContext, ModifyIssuesRequest) -> ModifyIssuesResponse
248 """pRPC API method that implements ModifyIssues.
249
250 Raises:
251 InputException if any given names do not have a valid format or if any
252 fields in the requested issue were invalid.
253 NoSuchIssueException if some issues weren't found.
254 NoSuchProjectException if no project was found for some given issues.
255 FilterRuleException if proposed issue changes violate any filter rules
256 that shows error.
257 PermissionException if user lacks sufficient permissions.
258 """
259 if not request.deltas:
260 return issues_pb2.ModifyIssuesResponse()
261 if len(request.deltas) > api_constants.MAX_MODIFY_ISSUES:
262 raise exceptions.InputException(
263 'Requesting %d updates when the allowed maximum is %d updates.' %
264 (len(request.deltas), api_constants.MAX_MODIFY_ISSUES))
265 impacted_issues_count = 0
266 for delta in request.deltas:
267 impacted_issues_count += (
268 len(delta.blocked_on_issues_remove) +
269 len(delta.blocking_issues_remove) +
270 len(delta.issue.blocking_issue_refs) +
271 len(delta.issue.blocked_on_issue_refs))
272 if 'merged_into_issue_ref' in delta.update_mask.paths:
273 impacted_issues_count += 1
274 if impacted_issues_count > api_constants.MAX_MODIFY_IMPACTED_ISSUES:
275 raise exceptions.InputException(
276 'Updates include %d impacted issues when the allowed maximum is %d.' %
277 (impacted_issues_count, api_constants.MAX_MODIFY_IMPACTED_ISSUES))
278 iid_delta_pairs = self.converter.IngestIssueDeltas(request.deltas)
279 with work_env.WorkEnv(mc, self.services) as we:
280 issues = we.ModifyIssues(
281 iid_delta_pairs,
282 attachment_uploads=self.converter.IngestAttachmentUploads(
283 request.uploads),
284 comment_content=request.comment_content,
285 send_email=self.converter.IngestNotifyType(request.notify_type))
286
287 return issues_pb2.ModifyIssuesResponse(
288 issues=self.converter.ConvertIssues(issues))
289
290 @monorail_servicer.PRPCMethod
291 def ModifyIssueApprovalValues(self, mc, request):
292 # type: (MonorailContext, ModifyIssueApprovalValuesRequest) ->
293 # ModifyIssueApprovalValuesResponse
294 """pRPC API method that implements ModifyIssueApprovalValues.
295
296 Raises:
297 InputException if any fields in the delta were invalid.
298 NoSuchIssueException: if the issue of any ApprovalValue isn't found.
299 NoSuchProjectException: if the parent project of any ApprovalValue isn't
300 found.
301 NoSuchUserException: if any user value provided isn't found.
302 PermissionException if user lacks sufficient permissions.
303 # TODO(crbug/monorail/7925): Not all of these are yet thrown.
304 """
305 if len(request.deltas) > api_constants.MAX_MODIFY_APPROVAL_VALUES:
306 raise exceptions.InputException(
307 'Requesting %d updates when the allowed maximum is %d updates.' %
308 (len(request.deltas), api_constants.MAX_MODIFY_APPROVAL_VALUES))
309 response = issues_pb2.ModifyIssueApprovalValuesResponse()
310 delta_specifications = self.converter.IngestApprovalDeltas(
311 request.deltas, mc.auth.user_id)
312 send_email = self.converter.IngestNotifyType(request.notify_type)
313 with work_env.WorkEnv(mc, self.services) as we:
314 # NOTE(crbug/monorail/7614): Until the referenced cleanup is complete,
315 # all servicer methods that are scoped to a single Project need to call
316 # mc.LookupLoggedInUserPerms.
317 # This method does not because it may be scoped to multiple projects.
318 issue_approval_values = we.BulkUpdateIssueApprovalsV3(
319 delta_specifications, request.comment_content, send_email=send_email)
320 api_avs = []
321 for issue, approval_value in issue_approval_values:
322 api_avs.extend(
323 self.converter.ConvertApprovalValues(
324 [approval_value],
325 issue.field_values,
326 issue.phases,
327 issue_id=issue.issue_id))
328 response.approval_values.extend(api_avs)
329 return response
330
331 @monorail_servicer.PRPCMethod
332 def ModifyCommentState(self, mc, request):
333 # type: (MonorailContext, ModifyCommentStateRequest) ->
334 # ModifyCommentStateResponse
335 """pRPC API method that implements ModifyCommentState.
336
337 We do not support changing between DELETED <-> SPAM. User must
338 undelete or unflag-as-spam first.
339
340 Raises:
341 NoSuchProjectException if the parent Project does not exist.
342 NoSuchIssueException: if the issue does not exist.
343 NoSuchCommentException: if the comment does not exist.
344 PermissionException if user lacks sufficient permissions.
345 ActionNotSupported if user requests unsupported state transitions.
346 """
347 (project_id, issue_id,
348 comment_num) = rnc.IngestCommentName(mc.cnxn, request.name, self.services)
349 with work_env.WorkEnv(mc, self.services) as we:
350 # TODO(crbug/monorail/7614): Eliminate the need to do this lookup.
351 project = we.GetProject(project_id)
352 mc.LookupLoggedInUserPerms(project)
353 issue = we.GetIssue(issue_id, use_cache=False)
354 comments_list = we.SafeListIssueComments(issue_id, 1, comment_num).items
355 try:
356 comment = comments_list[0]
357 except IndexError:
358 raise exceptions.NoSuchCommentException()
359
360 if request.state == issue_objects_pb2.IssueContentState.Value('ACTIVE'):
361 if comment.is_spam:
362 we.FlagComment(issue, comment, False)
363 elif comment.deleted_by != 0:
364 we.DeleteComment(issue, comment, delete=False)
365 else:
366 # No-op if already currently active
367 pass
368 elif request.state == issue_objects_pb2.IssueContentState.Value(
369 'DELETED'):
370 if (not comment.deleted_by) and (not comment.is_spam):
371 we.DeleteComment(issue, comment, delete=True)
372 elif comment.deleted_by and not comment.is_spam:
373 # No-op if already deleted
374 pass
375 else:
376 raise exceptions.ActionNotSupported(
377 'Cannot change comment state from spam to deleted.')
378 elif request.state == issue_objects_pb2.IssueContentState.Value('SPAM'):
379 if (not comment.deleted_by) and (not comment.is_spam):
380 we.FlagComment(issue, comment, True)
381 elif comment.is_spam:
382 # No-op if already spam
383 pass
384 else:
385 raise exceptions.ActionNotSupported(
386 'Cannot change comment state from deleted to spam.')
387 else:
388 raise exceptions.ActionNotSupported('Unsupported target comment state.')
389
390 # FlagComment does not have side effect on comment, must refresh.
391 refreshed_comment = we.SafeListIssueComments(issue_id, 1,
392 comment_num).items[0]
393
394 converted_comment = self.converter.ConvertComments(
395 issue_id, [refreshed_comment])[0]
396 return issues_pb2.ModifyCommentStateResponse(comment=converted_comment)