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