blob: e5ff08712d8776ec69964e9af32241f90923d2ba [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)
Adrià Vilanova Martínezac4a6442022-05-15 19:05:13 +0200237 ingested_attachments = self.converter.IngestAttachmentUploads(
238 request.uploads)
Copybara854996b2021-09-07 19:36:02 +0000239 with work_env.WorkEnv(mc, self.services) as we:
240 created_issue = we.MakeIssue(
Adrià Vilanova Martínezac4a6442022-05-15 19:05:13 +0200241 ingested_issue,
242 request.description,
243 send_email,
244 attachment_uploads=ingested_attachments)
Copybara854996b2021-09-07 19:36:02 +0000245 starred_issue = we.StarIssue(created_issue, True)
246
247 return self.converter.ConvertIssue(starred_issue)
248
249 @monorail_servicer.PRPCMethod
250 def ModifyIssues(self, mc, request):
251 # type: (MonorailContext, ModifyIssuesRequest) -> ModifyIssuesResponse
252 """pRPC API method that implements ModifyIssues.
253
254 Raises:
255 InputException if any given names do not have a valid format or if any
256 fields in the requested issue were invalid.
257 NoSuchIssueException if some issues weren't found.
258 NoSuchProjectException if no project was found for some given issues.
259 FilterRuleException if proposed issue changes violate any filter rules
260 that shows error.
261 PermissionException if user lacks sufficient permissions.
262 """
263 if not request.deltas:
264 return issues_pb2.ModifyIssuesResponse()
265 if len(request.deltas) > api_constants.MAX_MODIFY_ISSUES:
266 raise exceptions.InputException(
267 'Requesting %d updates when the allowed maximum is %d updates.' %
268 (len(request.deltas), api_constants.MAX_MODIFY_ISSUES))
269 impacted_issues_count = 0
270 for delta in request.deltas:
271 impacted_issues_count += (
272 len(delta.blocked_on_issues_remove) +
273 len(delta.blocking_issues_remove) +
274 len(delta.issue.blocking_issue_refs) +
275 len(delta.issue.blocked_on_issue_refs))
276 if 'merged_into_issue_ref' in delta.update_mask.paths:
277 impacted_issues_count += 1
278 if impacted_issues_count > api_constants.MAX_MODIFY_IMPACTED_ISSUES:
279 raise exceptions.InputException(
280 'Updates include %d impacted issues when the allowed maximum is %d.' %
281 (impacted_issues_count, api_constants.MAX_MODIFY_IMPACTED_ISSUES))
282 iid_delta_pairs = self.converter.IngestIssueDeltas(request.deltas)
283 with work_env.WorkEnv(mc, self.services) as we:
284 issues = we.ModifyIssues(
285 iid_delta_pairs,
286 attachment_uploads=self.converter.IngestAttachmentUploads(
287 request.uploads),
288 comment_content=request.comment_content,
289 send_email=self.converter.IngestNotifyType(request.notify_type))
290
291 return issues_pb2.ModifyIssuesResponse(
292 issues=self.converter.ConvertIssues(issues))
293
294 @monorail_servicer.PRPCMethod
295 def ModifyIssueApprovalValues(self, mc, request):
296 # type: (MonorailContext, ModifyIssueApprovalValuesRequest) ->
297 # ModifyIssueApprovalValuesResponse
298 """pRPC API method that implements ModifyIssueApprovalValues.
299
300 Raises:
301 InputException if any fields in the delta were invalid.
302 NoSuchIssueException: if the issue of any ApprovalValue isn't found.
303 NoSuchProjectException: if the parent project of any ApprovalValue isn't
304 found.
305 NoSuchUserException: if any user value provided isn't found.
306 PermissionException if user lacks sufficient permissions.
307 # TODO(crbug/monorail/7925): Not all of these are yet thrown.
308 """
309 if len(request.deltas) > api_constants.MAX_MODIFY_APPROVAL_VALUES:
310 raise exceptions.InputException(
311 'Requesting %d updates when the allowed maximum is %d updates.' %
312 (len(request.deltas), api_constants.MAX_MODIFY_APPROVAL_VALUES))
313 response = issues_pb2.ModifyIssueApprovalValuesResponse()
314 delta_specifications = self.converter.IngestApprovalDeltas(
315 request.deltas, mc.auth.user_id)
316 send_email = self.converter.IngestNotifyType(request.notify_type)
317 with work_env.WorkEnv(mc, self.services) as we:
318 # NOTE(crbug/monorail/7614): Until the referenced cleanup is complete,
319 # all servicer methods that are scoped to a single Project need to call
320 # mc.LookupLoggedInUserPerms.
321 # This method does not because it may be scoped to multiple projects.
322 issue_approval_values = we.BulkUpdateIssueApprovalsV3(
323 delta_specifications, request.comment_content, send_email=send_email)
324 api_avs = []
325 for issue, approval_value in issue_approval_values:
326 api_avs.extend(
327 self.converter.ConvertApprovalValues(
328 [approval_value],
329 issue.field_values,
330 issue.phases,
331 issue_id=issue.issue_id))
332 response.approval_values.extend(api_avs)
333 return response
334
335 @monorail_servicer.PRPCMethod
336 def ModifyCommentState(self, mc, request):
337 # type: (MonorailContext, ModifyCommentStateRequest) ->
338 # ModifyCommentStateResponse
339 """pRPC API method that implements ModifyCommentState.
340
341 We do not support changing between DELETED <-> SPAM. User must
342 undelete or unflag-as-spam first.
343
344 Raises:
345 NoSuchProjectException if the parent Project does not exist.
346 NoSuchIssueException: if the issue does not exist.
347 NoSuchCommentException: if the comment does not exist.
348 PermissionException if user lacks sufficient permissions.
349 ActionNotSupported if user requests unsupported state transitions.
350 """
351 (project_id, issue_id,
352 comment_num) = rnc.IngestCommentName(mc.cnxn, request.name, self.services)
353 with work_env.WorkEnv(mc, self.services) as we:
354 # TODO(crbug/monorail/7614): Eliminate the need to do this lookup.
355 project = we.GetProject(project_id)
356 mc.LookupLoggedInUserPerms(project)
357 issue = we.GetIssue(issue_id, use_cache=False)
358 comments_list = we.SafeListIssueComments(issue_id, 1, comment_num).items
359 try:
360 comment = comments_list[0]
361 except IndexError:
362 raise exceptions.NoSuchCommentException()
363
364 if request.state == issue_objects_pb2.IssueContentState.Value('ACTIVE'):
365 if comment.is_spam:
366 we.FlagComment(issue, comment, False)
367 elif comment.deleted_by != 0:
368 we.DeleteComment(issue, comment, delete=False)
369 else:
370 # No-op if already currently active
371 pass
372 elif request.state == issue_objects_pb2.IssueContentState.Value(
373 'DELETED'):
374 if (not comment.deleted_by) and (not comment.is_spam):
375 we.DeleteComment(issue, comment, delete=True)
376 elif comment.deleted_by and not comment.is_spam:
377 # No-op if already deleted
378 pass
379 else:
380 raise exceptions.ActionNotSupported(
381 'Cannot change comment state from spam to deleted.')
382 elif request.state == issue_objects_pb2.IssueContentState.Value('SPAM'):
383 if (not comment.deleted_by) and (not comment.is_spam):
384 we.FlagComment(issue, comment, True)
385 elif comment.is_spam:
386 # No-op if already spam
387 pass
388 else:
389 raise exceptions.ActionNotSupported(
390 'Cannot change comment state from deleted to spam.')
391 else:
392 raise exceptions.ActionNotSupported('Unsupported target comment state.')
393
394 # FlagComment does not have side effect on comment, must refresh.
395 refreshed_comment = we.SafeListIssueComments(issue_id, 1,
396 comment_num).items[0]
397
398 converted_comment = self.converter.ConvertComments(
399 issue_id, [refreshed_comment])[0]
400 return issues_pb2.ModifyCommentStateResponse(comment=converted_comment)