blob: 287c638006d490e98e8ffa340e40752c29df7227 [file] [log] [blame]
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +01001# Copyright 2016 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
5"""Servlet that implements the entry of new issues."""
6from __future__ import print_function
7from __future__ import division
8from __future__ import absolute_import
9
Copybara854996b2021-09-07 19:36:02 +000010import difflib
11import logging
12import string
13import time
14
15from businesslogic import work_env
16from features import hotlist_helpers
Copybara854996b2021-09-07 19:36:02 +000017from framework import exceptions
18from framework import framework_bizobj
19from framework import framework_constants
20from framework import framework_helpers
21from framework import framework_views
22from framework import permissions
23from framework import servlet
24from framework import template_helpers
25from framework import urls
26import ezt
27from tracker import field_helpers
28from tracker import template_helpers as issue_tmpl_helpers
29from tracker import tracker_bizobj
30from tracker import tracker_constants
31from tracker import tracker_helpers
32from tracker import tracker_views
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +010033from mrproto import tracker_pb2
Copybara854996b2021-09-07 19:36:02 +000034
35PLACEHOLDER_SUMMARY = 'Enter one-line summary'
36PHASES_WITH_MILESTONES = ['Beta', 'Stable', 'Stable-Exp', 'Stable-Full']
37CORP_RESTRICTION_LABEL = 'Restrict-View-Google'
38RESTRICTED_FLT_FIELDS = ['notice', 'whitepaper', 'm-approved']
39
40
41class IssueEntry(servlet.Servlet):
42 """IssueEntry shows a page with a simple form to enter a new issue."""
43
44 _PAGE_TEMPLATE = 'tracker/issue-entry-page.ezt'
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +010045 _MAIN_TAB_MODE = servlet.Servlet.MAIN_TAB_ISSUES
Copybara854996b2021-09-07 19:36:02 +000046
47 # The issue filing wizard is a separate app that posted back to Monorail's
48 # issue entry page. To make this possible for the wizard, we need to allow
49 # XHR-scoped XSRF tokens.
50 ALLOW_XHR = True
51
52 def AssertBasePermission(self, mr):
53 """Check whether the user has any permission to visit this page.
54
55 Args:
56 mr: commonly used info parsed from the request.
57 """
58 super(IssueEntry, self).AssertBasePermission(mr)
59 if not self.CheckPerm(mr, permissions.CREATE_ISSUE):
60 raise permissions.PermissionException(
61 'User is not allowed to enter an issue')
62
63 def GatherPageData(self, mr):
64 """Build up a dictionary of data values to use when rendering the page.
65
66 Args:
67 mr: commonly used info parsed from the request.
68
69 Returns:
70 Dict of values used by EZT for rendering the page.
71 """
72 with mr.profiler.Phase('getting config'):
73 config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
74
75 # In addition to checking perms, we adjust some default field values for
76 # project members.
77 is_member = framework_bizobj.UserIsInProject(
78 mr.project, mr.auth.effective_ids)
79 page_perms = self.MakePagePerms(
80 mr, None,
81 permissions.CREATE_ISSUE,
82 permissions.SET_STAR,
83 permissions.EDIT_ISSUE,
84 permissions.EDIT_ISSUE_SUMMARY,
85 permissions.EDIT_ISSUE_STATUS,
86 permissions.EDIT_ISSUE_OWNER,
87 permissions.EDIT_ISSUE_CC)
88
89
90 with work_env.WorkEnv(mr, self.services) as we:
91 userprefs = we.GetUserPrefs(mr.auth.user_id)
92 code_font = any(pref for pref in userprefs.prefs
93 if pref.name == 'code_font' and pref.value == 'true')
94
95 template = self._GetTemplate(mr.cnxn, config, mr.template_name, is_member)
96
97 if template.summary:
98 initial_summary = template.summary
99 initial_summary_must_be_edited = template.summary_must_be_edited
100 else:
101 initial_summary = PLACEHOLDER_SUMMARY
102 initial_summary_must_be_edited = True
103
104 if template.status:
105 initial_status = template.status
106 elif is_member:
107 initial_status = 'Accepted'
108 else:
109 initial_status = 'New' # not offering meta, only used in hidden field.
110
111 component_paths = []
112 for component_id in template.component_ids:
113 component_paths.append(
114 tracker_bizobj.FindComponentDefByID(component_id, config).path)
115 initial_components = ', '.join(component_paths)
116
117 if template.owner_id:
118 initial_owner = framework_views.MakeUserView(
119 mr.cnxn, self.services.user, template.owner_id)
120 elif template.owner_defaults_to_member and page_perms.EditIssue:
121 initial_owner = mr.auth.user_view
122 else:
123 initial_owner = None
124
125 if initial_owner:
126 initial_owner_name = initial_owner.email
127 owner_avail_state = initial_owner.avail_state
128 owner_avail_message_short = initial_owner.avail_message_short
129 else:
130 initial_owner_name = ''
131 owner_avail_state = None
132 owner_avail_message_short = None
133
134 # Check whether to allow attachments from the entry page
135 allow_attachments = tracker_helpers.IsUnderSoftAttachmentQuota(mr.project)
136
137 config_view = tracker_views.ConfigView(mr, self.services, config, template)
138 # If the user followed a link that specified the template name, make sure
139 # that it is also in the menu as the current choice.
140 # TODO(jeffcarp): Unit test this.
141 config_view.template_view.can_view = ezt.boolean(True)
142
143 # TODO(jeffcarp): Unit test this.
144 offer_templates = len(config_view.template_names) > 1
145 restrict_to_known = config.restrict_to_known
146 link_or_template_labels = mr.GetListParam('labels', template.labels)
147 labels, _derived_labels = tracker_bizobj.ExplicitAndDerivedNonMaskedLabels(
148 link_or_template_labels, [], config)
149
150 # Users with restrict_new_issues user pref automatically add R-V-G.
151 with work_env.WorkEnv(mr, self.services) as we:
152 userprefs = we.GetUserPrefs(mr.auth.user_id)
153 restrict_new_issues = any(
154 up.name == 'restrict_new_issues' and up.value == 'true'
155 for up in userprefs.prefs)
156 if restrict_new_issues:
157 if not any(lab.lower().startswith('restrict-view-') for lab in labels):
158 labels.append(CORP_RESTRICTION_LABEL)
159
160 field_user_views = tracker_views.MakeFieldUserViews(
161 mr.cnxn, template, self.services.user)
162 approval_ids = [av.approval_id for av in template.approval_values]
163 field_views = tracker_views.MakeAllFieldValueViews(
164 config, link_or_template_labels, [], template.field_values,
165 field_user_views, parent_approval_ids=approval_ids,
166 phases=template.phases)
167 # TODO(jojwang): monorail:6305, remove this hack when Edit perms for field
168 # values are implemented.
169 field_views = [view for view in field_views
170 if view.field_name.lower() not in RESTRICTED_FLT_FIELDS]
171 uneditable_fields = ezt.boolean(False)
172 for fv in field_views:
173 if permissions.CanEditValueForFieldDef(
174 mr.auth.effective_ids, mr.perms, mr.project, fv.field_def.field_def):
175 fv.is_editable = ezt.boolean(True)
176 else:
177 fv.is_editable = ezt.boolean(False)
178 uneditable_fields = ezt.boolean(True)
179
180 # TODO(jrobbins): remove "or []" after next release.
181 (prechecked_approvals, required_approval_ids,
182 phases) = issue_tmpl_helpers.GatherApprovalsPageData(
183 template.approval_values or [], template.phases, config)
184 approvals = [view for view in field_views if view.field_id in
185 approval_ids]
186
187 page_data = {
188 'issue_tab_mode':
189 'issueEntry',
190 'initial_summary':
191 initial_summary,
192 'template_summary':
193 initial_summary,
194 'clear_summary_on_click':
195 ezt.boolean(
196 initial_summary_must_be_edited and
197 'initial_summary' not in mr.form_overrides),
198 'must_edit_summary':
199 ezt.boolean(initial_summary_must_be_edited),
200 'initial_description':
201 template.content,
202 'template_name':
203 template.name,
204 'component_required':
205 ezt.boolean(template.component_required),
206 'initial_status':
207 initial_status,
208 'initial_owner':
209 initial_owner_name,
210 'owner_avail_state':
211 owner_avail_state,
212 'owner_avail_message_short':
213 owner_avail_message_short,
214 'initial_components':
215 initial_components,
216 'initial_cc':
217 '',
218 'initial_blocked_on':
219 '',
220 'initial_blocking':
221 '',
222 'initial_hotlists':
223 '',
224 'labels':
225 labels,
226 'fields':
227 field_views,
228 'any_errors':
229 ezt.boolean(mr.errors.AnyErrors()),
230 'page_perms':
231 page_perms,
232 'allow_attachments':
233 ezt.boolean(allow_attachments),
234 'max_attach_size':
235 template_helpers.BytesKbOrMb(
236 framework_constants.MAX_POST_BODY_SIZE),
237 'offer_templates':
238 ezt.boolean(offer_templates),
239 'config':
240 config_view,
241 'restrict_to_known':
242 ezt.boolean(restrict_to_known),
243 'is_member':
244 ezt.boolean(is_member),
245 'code_font':
246 ezt.boolean(code_font),
247 # The following are necessary for displaying phases that come with
248 # this template. These are read-only.
249 'allow_edit':
250 ezt.boolean(False),
251 'uneditable_fields':
252 uneditable_fields,
253 'initial_phases':
254 phases,
255 'approvals':
256 approvals,
257 'prechecked_approvals':
258 prechecked_approvals,
259 'required_approval_ids':
260 required_approval_ids,
261 # See monorail:4692 and the use of PHASES_WITH_MILESTONES
262 # in elements/flt/mr-launch-overview/mr-phase.js
263 'issue_phase_names':
264 list(
265 {
266 phase.name.lower()
267 for phase in phases
268 if phase.name in PHASES_WITH_MILESTONES
269 }),
270 }
271
272 return page_data
273
274 def GatherHelpData(self, mr, page_data):
275 """Return a dict of values to drive on-page user help.
276
277 Args:
278 mr: commonly used info parsed from the request.
279 page_data: Dictionary of base and page template data.
280
281 Returns:
282 A dict of values to drive on-page user help, to be added to page_data.
283 """
284 help_data = super(IssueEntry, self).GatherHelpData(mr, page_data)
285 dismissed = []
286 if mr.auth.user_pb:
287 with work_env.WorkEnv(mr, self.services) as we:
288 userprefs = we.GetUserPrefs(mr.auth.user_id)
289 dismissed = [
290 pv.name for pv in userprefs.prefs if pv.value == 'true']
291 is_privileged_domain_user = framework_bizobj.IsPriviledgedDomainUser(
292 mr.auth.user_pb.email)
293 if (mr.auth.user_id and
294 'privacy_click_through' not in dismissed):
295 help_data['cue'] = 'privacy_click_through'
296 elif (mr.auth.user_id and
297 'code_of_conduct' not in dismissed):
298 help_data['cue'] = 'code_of_conduct'
299
300 help_data.update({
301 'is_privileged_domain_user': ezt.boolean(is_privileged_domain_user),
302 })
303 return help_data
304
305 def ProcessFormData(self, mr, post_data):
306 """Process the issue entry form.
307
308 Args:
309 mr: commonly used info parsed from the request.
310 post_data: The post_data dict for the current request.
311
312 Returns:
313 String URL to redirect the user to after processing.
314 """
315 config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)
316
317 parsed = tracker_helpers.ParseIssueRequest(
318 mr.cnxn, post_data, self.services, mr.errors, mr.project_name)
319
320 # Updates parsed.labels and parsed.fields in place.
321 field_helpers.ShiftEnumFieldsIntoLabels(
322 parsed.labels, parsed.labels_remove, parsed.fields.vals,
323 parsed.fields.vals_remove, config)
324
325 labels = _DiscardUnusedTemplateLabelPrefixes(parsed.labels)
326
327 is_member = framework_bizobj.UserIsInProject(
328 mr.project, mr.auth.effective_ids)
329 template = self._GetTemplate(
330 mr.cnxn, config, parsed.template_name, is_member)
331
332 (approval_values,
333 phases) = issue_tmpl_helpers.FilterApprovalsAndPhases(
334 template.approval_values or [], template.phases, config)
335
336 # Issue PB with only approval_values and labels filled out, for the purpose
337 # of computing applicable fields.
338 partial_issue = tracker_pb2.Issue(
339 approval_values=approval_values, labels=labels)
340 applicable_fields = field_helpers.ListApplicableFieldDefs(
341 [partial_issue], config)
342
343 bounce_labels = parsed.labels[:]
344 bounce_fields = tracker_views.MakeBounceFieldValueViews(
345 parsed.fields.vals,
346 parsed.fields.phase_vals,
347 config,
348 applicable_fields=applicable_fields)
349
350 phase_ids_by_name = {
351 phase.name.lower(): [phase.phase_id] for phase in template.phases}
352 field_values = field_helpers.ParseFieldValues(
353 mr.cnxn, self.services.user, parsed.fields.vals,
354 parsed.fields.phase_vals, config,
355 phase_ids_by_name=phase_ids_by_name)
356
357 component_ids = tracker_helpers.LookupComponentIDs(
358 parsed.components.paths, config, mr.errors)
359
360 if not parsed.summary.strip() or parsed.summary == PLACEHOLDER_SUMMARY:
361 mr.errors.summary = 'Summary is required'
362
363 if not parsed.comment.strip():
364 mr.errors.comment = 'A description is required'
365
366 if len(parsed.comment) > tracker_constants.MAX_COMMENT_CHARS:
367 mr.errors.comment = 'Comment is too long'
368 if len(parsed.summary) > tracker_constants.MAX_SUMMARY_CHARS:
369 mr.errors.summary = 'Summary is too long'
370
371 if _MatchesTemplate(parsed.comment, template):
372 mr.errors.comment = 'Template must be filled out.'
373
374 if parsed.users.owner_id is None:
375 mr.errors.owner = 'Invalid owner username'
376 else:
377 valid, msg = tracker_helpers.IsValidIssueOwner(
378 mr.cnxn, mr.project, parsed.users.owner_id, self.services)
379 if not valid:
380 mr.errors.owner = msg
381
382 if None in parsed.users.cc_ids:
383 mr.errors.cc = 'Invalid Cc username'
384
385 field_helpers.AssertCustomFieldsEditPerms(
386 mr, config, field_values, [], [], labels, [])
387 field_helpers.ApplyRestrictedDefaultValues(
388 mr, config, field_values, labels, template.field_values,
389 template.labels)
390
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100391 # This ValidateLabels call is redundant with work already done
392 # in CreateIssue. However, this instance passes in an ezt_errors object
393 # to allow showing related errors next to the fields they happen on.
394 field_helpers.ValidateLabels(
395 mr.cnxn, self.services, mr.project_id, labels, ezt_errors=mr.errors)
396
Copybara854996b2021-09-07 19:36:02 +0000397 # This ValidateCustomFields call is redundant with work already done
398 # in CreateIssue. However, this instance passes in an ezt_errors object
399 # to allow showing related errors next to the fields they happen on.
400 field_helpers.ValidateCustomFields(
401 mr.cnxn,
402 self.services,
403 field_values,
404 config,
405 mr.project,
406 ezt_errors=mr.errors,
407 issue=partial_issue)
408
409 hotlist_pbs = ProcessParsedHotlistRefs(
410 mr, self.services, parsed.hotlists.hotlist_refs)
411
412 if not mr.errors.AnyErrors():
413 with work_env.WorkEnv(mr, self.services) as we:
414 try:
415 if parsed.attachments:
416 new_bytes_used = tracker_helpers.ComputeNewQuotaBytesUsed(
417 mr.project, parsed.attachments)
418 # TODO(jrobbins): Make quota be calculated and stored as
419 # part of applying the comment.
420 self.services.project.UpdateProject(
421 mr.cnxn, mr.project.project_id,
422 attachment_bytes_used=new_bytes_used)
423
424 marked_description = tracker_helpers.MarkupDescriptionOnInput(
425 parsed.comment, template.content)
426 has_star = 'star' in post_data and post_data['star'] == '1'
427
428 if approval_values:
429 _AttachDefaultApprovers(config, approval_values)
430
431 # To preserve previous behavior, do not raise filter rule errors.
432 issue, _ = we.CreateIssue(
433 mr.project_id,
434 parsed.summary,
435 parsed.status,
436 parsed.users.owner_id,
437 parsed.users.cc_ids,
438 labels,
439 field_values,
440 component_ids,
441 marked_description,
442 blocked_on=parsed.blocked_on.iids,
443 blocking=parsed.blocking.iids,
444 dangling_blocked_on=[
445 tracker_pb2.DanglingIssueRef(ext_issue_identifier=ref_string)
446 for ref_string in parsed.blocked_on.federated_ref_strings
447 ],
448 dangling_blocking=[
449 tracker_pb2.DanglingIssueRef(ext_issue_identifier=ref_string)
450 for ref_string in parsed.blocking.federated_ref_strings
451 ],
452 attachments=parsed.attachments,
453 approval_values=approval_values,
454 phases=phases,
455 raise_filter_errors=False)
456
457 if has_star:
458 we.StarIssue(issue, True)
459
460 if hotlist_pbs:
461 hotlist_ids = {hotlist.hotlist_id for hotlist in hotlist_pbs}
462 issue_tuple = (issue.issue_id, mr.auth.user_id, int(time.time()),
463 '')
464 self.services.features.AddIssueToHotlists(
465 mr.cnxn, hotlist_ids, issue_tuple, self.services.issue,
466 self.services.chart)
467
468 except exceptions.OverAttachmentQuota:
469 mr.errors.attachments = 'Project attachment quota exceeded.'
470 except exceptions.InputException as e:
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100471 if 'Undefined or deprecated component with id' in str(e):
Copybara854996b2021-09-07 19:36:02 +0000472 mr.errors.components = 'Undefined or deprecated component'
473
474 mr.template_name = parsed.template_name
475 if mr.errors.AnyErrors():
476 self.PleaseCorrect(
477 mr, initial_summary=parsed.summary, initial_status=parsed.status,
478 initial_owner=parsed.users.owner_username,
479 initial_cc=', '.join(parsed.users.cc_usernames),
480 initial_components=', '.join(parsed.components.paths),
481 initial_comment=parsed.comment, labels=bounce_labels,
482 fields=bounce_fields, template_name=parsed.template_name,
483 initial_blocked_on=parsed.blocked_on.entered_str,
484 initial_blocking=parsed.blocking.entered_str,
485 initial_hotlists=parsed.hotlists.entered_str,
486 component_required=ezt.boolean(template.component_required))
487 return
488
489 # format a redirect url
490 return framework_helpers.FormatAbsoluteURL(
491 mr, urls.ISSUE_DETAIL, id=issue.local_id)
492
493 def _GetTemplate(self, cnxn, config, template_name, is_member):
494 """Tries to fetch template by name and implements default template logic
495 if not found."""
496 template = None
497 if template_name:
498 template_name = template_name.replace('+', ' ')
499 template = self.services.template.GetTemplateByName(cnxn,
500 template_name, config.project_id)
501
502 if not template:
503 if is_member:
504 template_id = config.default_template_for_developers
505 else:
506 template_id = config.default_template_for_users
507 template = self.services.template.GetTemplateById(cnxn, template_id)
508 # If the default templates were deleted, load all and pick the first one.
509 if not template:
510 templates = self.services.template.GetProjectTemplates(cnxn,
511 config.project_id)
512 assert len(templates) > 0, 'Project has no templates!'
513 template = templates[0]
514
515 return template
516
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100517 def GetIssueEntry(self, **kwargs):
518 return self.handler(**kwargs)
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +0200519
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +0100520 def PostIssueEntry(self, **kwargs):
521 return self.handler(**kwargs)
Adrià Vilanova Martínezde942802022-07-15 14:06:55 +0200522
Copybara854996b2021-09-07 19:36:02 +0000523
524def _AttachDefaultApprovers(config, approval_values):
525 approval_defs_by_id = {ad.approval_id: ad for ad in config.approval_defs}
526 for av in approval_values:
527 ad = approval_defs_by_id.get(av.approval_id)
528 if ad:
529 av.approver_ids = ad.approver_ids[:]
530 else:
531 logging.info('ApprovalDef with approval_id %r could not be found',
532 av.approval_id)
533
534
535def _MatchesTemplate(content, template):
536 content = content.strip(string.whitespace)
537 template_content = template.content.strip(string.whitespace)
538 diff = difflib.unified_diff(content.splitlines(),
539 template_content.splitlines())
540 return len('\n'.join(diff)) == 0
541
542
543def _DiscardUnusedTemplateLabelPrefixes(labels):
544 """Drop any labels that end in '-?'.
545
546 Args:
547 labels: a list of label strings.
548
549 Returns:
550 A list of the same labels, but without any that end with '-?'.
551 Those label prefixes in the new issue templates are intended to
552 prompt the user to enter some label with that prefix, but if
553 nothing is entered there, we do not store anything.
554 """
555 return [lab for lab in labels
556 if not lab.endswith('-?')]
557
558
559def ProcessParsedHotlistRefs(mr, services, parsed_hotlist_refs):
560 """Process a list of ParsedHotlistRefs, returning referenced hotlists.
561
562 This function validates the given ParsedHotlistRefs using four checks; if all
563 of them succeed, then it returns the corresponding hotlist protobuf objects.
564 If any of them fail, it sets the appropriate error string in mr.errors, and
565 returns an empty list.
566
567 Args:
568 mr: the MonorailRequest object
569 services: the service manager
570 parsed_hotlist_refs: a list of ParsedHotlistRef objects
571
572 Returns:
573 on valid input, a list of hotlist protobuf objects
574 if a check fails (and the input is thus considered invalid), an empty list
575
576 Side-effects:
577 if any of the checks fails, set mr.errors.hotlists to a descriptive error
578 """
579 # Pre-processing; common pieces used by functions later.
580 user_hotlist_pbs = services.features.GetHotlistsByUserID(
581 mr.cnxn, mr.auth.user_id)
582 user_hotlist_owners_ids = {hotlist.owner_ids[0]
583 for hotlist in user_hotlist_pbs}
584 user_hotlist_owners_to_emails = services.user.LookupUserEmails(
585 mr.cnxn, user_hotlist_owners_ids)
586 user_hotlist_emails_to_owners = {v: k
587 for k, v in user_hotlist_owners_to_emails.items()}
588 user_hotlist_refs_to_pbs = {
589 hotlist_helpers.HotlistRef(hotlist.owner_ids[0], hotlist.name): hotlist
590 for hotlist in user_hotlist_pbs }
591 short_refs = list()
592 full_refs = list()
593 for parsed_ref in parsed_hotlist_refs:
594 if parsed_ref.user_email is None:
595 short_refs.append(parsed_ref)
596 else:
597 full_refs.append(parsed_ref)
598
599 invalid_names = hotlist_helpers.InvalidParsedHotlistRefsNames(
600 parsed_hotlist_refs, user_hotlist_pbs)
601 if invalid_names:
602 mr.errors.hotlists = (
603 'You have no hotlist(s) named: %s' % ', '.join(invalid_names))
604 return []
605
606 ambiguous_names = hotlist_helpers.AmbiguousShortrefHotlistNames(
607 short_refs, user_hotlist_pbs)
608 if ambiguous_names:
609 mr.errors.hotlists = (
610 'Ambiguous hotlist(s) specified: %s' % ', '.join(ambiguous_names))
611 return []
612
613 # At this point, all refs' named hotlists are guaranteed to exist, and
614 # short refs are guaranteed to be unambiguous;
615 # therefore, short refs are also valid.
616 short_refs_hotlist_names = {sref.hotlist_name for sref in short_refs}
617 shortref_valid_pbs = [hotlist for hotlist in user_hotlist_pbs
618 if hotlist.name in short_refs_hotlist_names]
619
620 invalid_emails = hotlist_helpers.InvalidParsedHotlistRefsEmails(
621 full_refs, user_hotlist_emails_to_owners)
622 if invalid_emails:
623 mr.errors.hotlists = (
624 'You have no hotlist(s) owned by: %s' % ', '.join(invalid_emails))
625 return []
626
627 fullref_valid_pbs, invalid_refs = (
628 hotlist_helpers.GetHotlistsOfParsedHotlistFullRefs(
629 full_refs, user_hotlist_emails_to_owners, user_hotlist_refs_to_pbs))
630 if invalid_refs:
631 invalid_refs_readable = [':'.join(parsed_ref)
632 for parsed_ref in invalid_refs]
633 mr.errors.hotlists = (
634 'Not in your hotlist(s): %s' % ', '.join(invalid_refs_readable))
635 return []
636
637 hotlist_pbs = shortref_valid_pbs + fullref_valid_pbs
638
639 return hotlist_pbs