Example #1
0
    def Run(self, mc, services):
        """Updates an issue based on the parsed commands."""
        try:
            issue = services.issue.GetIssueByLocalID(mc.cnxn,
                                                     self.project.project_id,
                                                     self.local_id,
                                                     use_cache=False)
        except exceptions.NoSuchIssueException:
            return  # Issue does not exist, so do nothing

        delta = tracker_pb2.IssueDelta()

        allow_edit = permissions.CanEditIssue(mc.auth.effective_ids, mc.perms,
                                              self.project, issue)

        if allow_edit:
            delta.summary = self.parser.summary or issue.summary
            if self.parser.status is None:
                delta.status = issue.status
            else:
                delta.status = self.parser.status

            if self.parser.owner_id is None:
                delta.owner_id = issue.owner_id
            else:
                delta.owner_id = self.parser.owner_id

            delta.cc_ids_add = list(self.parser.cc_add)
            delta.cc_ids_remove = list(self.parser.cc_remove)
            delta.labels_add = self.parser.labels_add
            delta.labels_remove = self.parser.labels_remove
            # TODO(jrobbins): allow editing of custom fields

        with work_env.WorkEnv(mc, services) as we:
            we.UpdateIssue(issue,
                           delta,
                           self.description,
                           inbound_message=self.inbound_message)

        logging.info('Updated issue %s:%s', self.project.project_name,
                     issue.local_id)
Example #2
0
def ComputeIssueChangeAddressPermList(
    cnxn, ids_to_consider, project, issue, services, omit_addrs,
    users_by_id, pref_check_function=lambda u: u.notify_issue_change):
  """Return a list of user email addresses to notify of an issue change.

  User email addresses are determined by looking up the given user IDs
  in the given users_by_id dict.

  Args:
    cnxn: connection to SQL database.
    ids_to_consider: list of user IDs for users interested in this issue.
    project: Project PB for the project containing this issue.
    issue: Issue PB for the issue that was updated.
    services: Services.
    omit_addrs: set of strings for email addresses to not notify because
        they already know.
    users_by_id: dict {user_id: user_view} user info.
    pref_check_function: optional function to use to check if a certain
        User PB has a preference set to receive the email being sent.  It
        defaults to "If I am in the issue's owner or cc field", but it
        can be set to check "If I starred the issue."

  Returns:
    A list of AddrPerm objects.
  """
  memb_addr_perm_list = []
  logging.info('Considering %r ', ids_to_consider)
  all_user_prefs = services.user.GetUsersPrefs(cnxn, ids_to_consider)
  for user_id in ids_to_consider:
    if user_id == framework_constants.NO_USER_SPECIFIED:
      continue
    user = services.user.GetUser(cnxn, user_id)
    # Notify people who have a pref set, or if they have no User PB
    # because the pref defaults to True.
    if user and not pref_check_function(user):
      logging.info('Not notifying %r: user preference', user.email)
      continue
    # TODO(jrobbins): doing a bulk operation would reduce DB load.
    auth = authdata.AuthData.FromUserID(cnxn, user_id, services)
    perms = permissions.GetPermissions(user, auth.effective_ids, project)
    config = services.config.GetProjectConfig(cnxn, project.project_id)
    granted_perms = tracker_bizobj.GetGrantedPerms(
        issue, auth.effective_ids, config)

    if not permissions.CanViewIssue(
        auth.effective_ids, perms, project, issue,
        granted_perms=granted_perms):
      logging.info('Not notifying %r: user cannot view issue', user.email)
      continue

    addr = users_by_id[user_id].email
    if addr in omit_addrs:
      logging.info('Not notifying %r: user already knows', user.email)
      continue

    recipient_is_member = bool(framework_bizobj.UserIsInProject(
        project, auth.effective_ids))

    reply_perm = REPLY_NOT_ALLOWED
    if project.process_inbound_email:
      if permissions.CanEditIssue(auth.effective_ids, perms, project, issue):
        reply_perm = REPLY_MAY_UPDATE
      elif permissions.CanCommentIssue(
          auth.effective_ids, perms, project, issue):
        reply_perm = REPLY_MAY_COMMENT

    memb_addr_perm_list.append(
      AddrPerm(recipient_is_member, addr, user, reply_perm,
               all_user_prefs[user_id]))

  logging.info('For %s %s, will notify: %r',
               project.project_name, issue.local_id,
               [ap.address for ap in memb_addr_perm_list])

  return memb_addr_perm_list
Example #3
0
  def GatherPageData(self, mr):
    """Build up a dictionary of data values to use when rendering the page.

    Args:
      mr: commonly used info parsed from the request.

    Returns:
      Dict of values used by EZT for rendering the page.
    """
    if mr.local_id is None:
      self.abort(404, 'no issue specified')
    with work_env.WorkEnv(mr, self.services) as we:
      # Signed in users could edit the issue, so it must be fresh.
      use_cache = not mr.auth.user_id
      issue = we.GetIssueByLocalID(
          mr.project_id, mr.local_id, use_cache=use_cache)

      # We give no explanation of missing issues on the peek page.
      if issue.deleted:
        self.abort(404, 'issue not found')

      star_cnxn = sql.MonorailConnection()
      star_promise = framework_helpers.Promise(
          we.IsIssueStarred, issue, cnxn=star_cnxn)

      config = we.GetProjectConfig(mr.project_id)
      comments = we.ListIssueComments(issue)

    descriptions, visible_comments, cmnt_pagination = PaginateComments(
        mr, issue, comments, config, self.services)

    with mr.profiler.Phase('making user proxies'):
      involved_user_ids = tracker_bizobj.UsersInvolvedInIssues([issue])
      group_ids = self.services.usergroup.DetermineWhichUserIDsAreGroups(
          mr.cnxn, involved_user_ids)
      comment_user_ids = tracker_bizobj.UsersInvolvedInCommentList(
          descriptions + visible_comments)
      users_by_id = framework_views.MakeAllUserViews(
          mr.cnxn, self.services.user, involved_user_ids,
          comment_user_ids, group_ids=group_ids)
      framework_views.RevealAllEmailsToMembers(mr.auth, mr.project, users_by_id)

    (issue_view, description_views,
     comment_views) = self._MakeIssueAndCommentViews(
         mr, issue, users_by_id, descriptions, visible_comments, config,
         issue_reporters=[], comment_reporters=[])

    with mr.profiler.Phase('getting starring info'):
      starred = star_promise.WaitAndGetValue()
      star_cnxn.Close()
      permit_edit = permissions.CanEditIssue(
          mr.auth.effective_ids, mr.perms, mr.project, issue)

    mr.ComputeColSpec(config)
    restrict_to_known = config.restrict_to_known

    page_perms = self.MakePagePerms(
        mr, issue,
        permissions.CREATE_ISSUE,
        permissions.SET_STAR,
        permissions.EDIT_ISSUE,
        permissions.EDIT_ISSUE_SUMMARY,
        permissions.EDIT_ISSUE_STATUS,
        permissions.EDIT_ISSUE_OWNER,
        permissions.EDIT_ISSUE_CC,
        permissions.DELETE_ISSUE,
        permissions.ADD_ISSUE_COMMENT,
        permissions.DELETE_OWN,
        permissions.DELETE_ANY,
        permissions.VIEW_INBOUND_MESSAGES)
    page_perms.EditIssue = ezt.boolean(permit_edit)

    prevent_restriction_removal = (
        mr.project.only_owners_remove_restrictions and
        not framework_bizobj.UserOwnsProject(
            mr.project, mr.auth.effective_ids))

    cmd_slots, default_slot_num = self.services.features.GetRecentCommands(
        mr.cnxn, mr.auth.user_id, mr.project_id)
    cmd_slot_views = [
        template_helpers.EZTItem(
            slot_num=slot_num, command=command, comment=comment)
        for slot_num, command, comment in cmd_slots]

    previous_locations = self.GetPreviousLocations(mr, issue)

    return {
        'issue_tab_mode': 'issueDetail',
        'issue': issue_view,
        'description': description_views,
        'comments': comment_views,
        'labels': issue.labels,
        'num_detail_rows': len(comment_views) + 4,
        'noisy': ezt.boolean(tracker_helpers.IsNoisy(
            len(comment_views), issue.star_count)),

        'cmnt_pagination': cmnt_pagination,
        'colspec': mr.col_spec,
        'searchtip': 'You can jump to any issue by number',
        'starred': ezt.boolean(starred),

        'pagegen': str(int(time.time() * 1000000)),

        'restrict_to_known': ezt.boolean(restrict_to_known),
        'prevent_restriction_removal': ezt.boolean(
            prevent_restriction_removal),

        'statuses_offer_merge': config.statuses_offer_merge,
        'page_perms': page_perms,
        'cmd_slots': cmd_slot_views,
        'default_slot_num': default_slot_num,
        'quick_edit_submit_url': tracker_helpers.FormatRelativeIssueURL(
            issue.project_name, urls.ISSUE_PEEK + '.do', id=issue.local_id),
        'previous_locations': previous_locations,
        # for template issue-meta-part shared by issuedetail servlet
        'user_remaining_hotlists': [],
        'user_issue_hotlists': [],
        'involved_users_issue_hotlists': [],
        'remaining_issue_hotlists': [],
        }
Example #4
0
  def ProcessFormData(self, mr, post_data):
    """Process the posted issue update form.

    Args:
      mr: commonly used info parsed from the request.
      post_data: HTML form data from the request.

    Returns:
      String URL to redirect the user to, or None if response was already sent.
    """
    cmd = post_data.get('cmd', '')
    send_email = 'send_email' in post_data
    comment = post_data.get('comment', '')
    slot_used = int(post_data.get('slot_used', 1))
    page_generation_time = int(post_data['pagegen'])
    with work_env.WorkEnv(mr, self.services) as we:
      issue = we.GetIssueByLocalID(
          mr.project_id, mr.local_id, use_cache=False)
      old_owner_id = tracker_bizobj.GetOwnerId(issue)
      config = we.GetProjectConfig(mr.project_id)

    summary, status, owner_id, cc_ids, labels = commands.ParseQuickEditCommand(
        mr.cnxn, cmd, issue, config, mr.auth.user_id, self.services)
    component_ids = issue.component_ids  # TODO(jrobbins): component commands
    field_values = issue.field_values  # TODO(jrobbins): edit custom fields

    permit_edit = permissions.CanEditIssue(
        mr.auth.effective_ids, mr.perms, mr.project, issue)
    if not permit_edit:
      raise permissions.PermissionException(
          'User is not allowed to edit this issue')

    amendments, comment_pb = self.services.issue.ApplyIssueComment(
        mr.cnxn, self.services, mr.auth.user_id,
        mr.project_id, mr.local_id, summary, status, owner_id, cc_ids,
        labels, field_values, component_ids, issue.blocked_on_iids,
        issue.blocking_iids, issue.dangling_blocked_on_refs,
        issue.dangling_blocking_refs, issue.merged_into,
        page_gen_ts=page_generation_time, comment=comment)
    self.services.project.UpdateRecentActivity(
        mr.cnxn, mr.project.project_id)

    if send_email:
      if amendments or comment.strip():
        send_notifications.PrepareAndSendIssueChangeNotification(
            issue.issue_id, mr.request.host, mr.auth.user_id,
            send_email=send_email, old_owner_id=old_owner_id,
            comment_id=comment_pb.id)

    # TODO(jrobbins): allow issue merge via quick-edit.

    self.services.features.StoreRecentCommand(
        mr.cnxn, mr.auth.user_id, mr.project_id, slot_used, cmd, comment)

    # TODO(jrobbins): this is very similar to a block of code in issuebulkedit.
    mr.can = int(post_data['can'])
    mr.query = post_data.get('q', '')
    mr.col_spec = post_data.get('colspec', '')
    mr.sort_spec = post_data.get('sort', '')
    mr.group_by_spec = post_data.get('groupby', '')
    mr.start = int(post_data['start'])
    mr.num = int(post_data['num'])
    preview_issue_ref_str = '%s:%d' % (issue.project_name, issue.local_id)
    return tracker_helpers.FormatIssueListURL(
        mr, config, preview=preview_issue_ref_str, updated=mr.local_id,
        ts=int(time.time()))
Example #5
0
def convert_issue(cls, issue, mar, services):
    """Convert Monorail Issue PB to API IssuesGetInsertResponse."""

    config = services.config.GetProjectConfig(mar.cnxn, issue.project_id)
    granted_perms = tracker_bizobj.GetGrantedPerms(issue,
                                                   mar.auth.effective_ids,
                                                   config)
    issue_project = services.project.GetProject(mar.cnxn, issue.project_id)
    component_list = []
    for cd in config.component_defs:
        cid = cd.component_id
        if cid in issue.component_ids:
            component_list.append(cd.path)
    cc_list = [convert_person(p, mar.cnxn, services) for p in issue.cc_ids]
    cc_list = [p for p in cc_list if p is not None]
    field_values_list = []
    fds_by_id = {fd.field_id: fd for fd in config.field_defs}
    phases_by_id = {phase.phase_id: phase for phase in issue.phases}
    for fv in issue.field_values:
        fd = fds_by_id.get(fv.field_id)
        if not fd:
            logging.warning('Custom field %d of project %s does not exist',
                            fv.field_id, issue_project.project_name)
            continue
        val = None
        if fv.user_id:
            val = _get_user_email(services.user, mar.cnxn, fv.user_id)
        else:
            val = tracker_bizobj.GetFieldValue(fv, {})
            if not isinstance(val, string_types):
                val = str(val)
        new_fv = api_pb2_v1.FieldValue(fieldName=fd.field_name,
                                       fieldValue=val,
                                       derived=fv.derived)
        if fd.approval_id:  # Attach parent approval name
            approval_fd = fds_by_id.get(fd.approval_id)
            if not approval_fd:
                logging.warning(
                    'Parent approval field %d of field %s does not exist',
                    fd.approval_id, fd.field_name)
            else:
                new_fv.approvalName = approval_fd.field_name
        elif fv.phase_id:  # Attach phase name
            phase = phases_by_id.get(fv.phase_id)
            if not phase:
                logging.warning('Phase %d for field %s does not exist',
                                fv.phase_id, fd.field_name)
            else:
                new_fv.phaseName = phase.name
        field_values_list.append(new_fv)
    approval_values_list = convert_approvals(mar.cnxn, issue.approval_values,
                                             services, config, issue.phases)
    phases_list = convert_phases(issue.phases)
    with work_env.WorkEnv(mar, services) as we:
        starred = we.IsIssueStarred(issue)
    resp = cls(
        kind='monorail#issue',
        id=issue.local_id,
        title=issue.summary,
        summary=issue.summary,
        projectId=issue_project.project_name,
        stars=issue.star_count,
        starred=starred,
        status=issue.status,
        state=(api_pb2_v1.IssueState.open
               if tracker_helpers.MeansOpenInProject(
                   tracker_bizobj.GetStatus(issue), config) else
               api_pb2_v1.IssueState.closed),
        labels=issue.labels,
        components=component_list,
        author=convert_person(issue.reporter_id, mar.cnxn, services),
        owner=convert_person(issue.owner_id, mar.cnxn, services),
        cc=cc_list,
        updated=datetime.datetime.fromtimestamp(issue.modified_timestamp),
        published=datetime.datetime.fromtimestamp(issue.opened_timestamp),
        blockedOn=convert_issue_ids(issue.blocked_on_iids, mar, services),
        blocking=convert_issue_ids(issue.blocking_iids, mar, services),
        canComment=permissions.CanCommentIssue(mar.auth.effective_ids,
                                               mar.perms,
                                               issue_project,
                                               issue,
                                               granted_perms=granted_perms),
        canEdit=permissions.CanEditIssue(mar.auth.effective_ids,
                                         mar.perms,
                                         issue_project,
                                         issue,
                                         granted_perms=granted_perms),
        fieldValues=field_values_list,
        approvalValues=approval_values_list,
        phases=phases_list)
    if issue.closed_timestamp > 0:
        resp.closed = datetime.datetime.fromtimestamp(issue.closed_timestamp)
    if issue.merged_into:
        resp.mergedInto = convert_issue_ids([issue.merged_into], mar,
                                            services)[0]
    if issue.owner_modified_timestamp:
        resp.owner_modified = datetime.datetime.fromtimestamp(
            issue.owner_modified_timestamp)
    if issue.status_modified_timestamp:
        resp.status_modified = datetime.datetime.fromtimestamp(
            issue.status_modified_timestamp)
    if issue.component_modified_timestamp:
        resp.component_modified = datetime.datetime.fromtimestamp(
            issue.component_modified_timestamp)
    return resp
Example #6
0
  def ProcessIssueReply(
      self, cnxn, project, local_id, project_addr, from_addr, author_id,
      effective_ids, perms, body):
    """Examine an issue reply email body and add a comment to the issue.

    Args:
      cnxn: connection to SQL database.
      project: Project PB for the project containing the issue.
      local_id: int ID of the issue being replied to.
      project_addr: string email address used for outbound emails from
          that project.
      from_addr: string email address of the user who sent the email
          reply to our server.
      author_id: int user ID of user who sent the reply email.
      effective_ids: set of int user IDs for the user (including any groups),
          or an empty set if user is not signed in.
      perms: PermissionSet for the user who sent the reply email.
      body: string email body text of the reply email.

    Returns:
      A list of follow-up work items, e.g., to notify other users of
      the new comment, or to notify the user that their reply was not
      processed.

    Side-effect:
      Adds a new comment to the issue, if no error is reported.
    """
    try:
      issue = self.services.issue.GetIssueByLocalID(
          cnxn, project.project_id, local_id)
    except issue_svc.NoSuchIssueException:
      issue = None

    if not issue or issue.deleted:
      # The referenced issue was not found, e.g., it might have been
      # deleted, or someone messed with the subject line.  Reject it.
      return _MakeErrorMessageReplyTask(
          project_addr, from_addr, self._templates['no_artifact'],
          artifact_phrase='issue %d' % local_id,
          project_name=project.project_name)

    can_view = perms.CanUsePerm(
        permissions.VIEW, effective_ids, project,
        permissions.GetRestrictions(issue))
    can_comment = perms.CanUsePerm(
        permissions.ADD_ISSUE_COMMENT, effective_ids, project,
        permissions.GetRestrictions(issue))
    if not can_view or not can_comment:
      return _MakeErrorMessageReplyTask(
          project_addr, from_addr, self._templates['no_perms'],
          artifact_phrase='issue %d' % local_id,
          project_name=project.project_name)
    allow_edit = permissions.CanEditIssue(
        effective_ids, perms, project, issue)
    # TODO(jrobbins): if the user does not have EDIT_ISSUE and the inbound
    # email tries to make an edit, send back an error message.

    lines = body.strip().split('\n')
    uia = commitlogcommands.UpdateIssueAction(local_id)
    uia.Parse(cnxn, project.project_name, author_id, lines, self.services,
              strip_quoted_lines=True)
    uia.Run(cnxn, self.services, allow_edit=allow_edit)
Example #7
0
    def ProcessFormData(self, mr, post_data):
        """Process the posted issue update form.

    Args:
      mr: commonly used info parsed from the request.
      post_data: HTML form data from the request.

    Returns:
      String URL to redirect the user to after processing.
    """
        if not mr.local_id_list:
            logging.info('missing issue local IDs, probably tampered')
            self.response.status = httplib.BAD_REQUEST
            return

        # Check that the user is logged in; anon users cannot update issues.
        if not mr.auth.user_id:
            logging.info('user was not logged in, cannot update issue')
            self.response.status = httplib.BAD_REQUEST  # xxx should raise except
            return

        # Check that the user has permission to add a comment, and to enter
        # metadata if they are trying to do that.
        if not self.CheckPerm(mr, permissions.ADD_ISSUE_COMMENT):
            logging.info('user has no permission to add issue comment')
            self.response.status = httplib.BAD_REQUEST
            return

        if not self.CheckPerm(mr, permissions.EDIT_ISSUE):
            logging.info('user has no permission to edit issue metadata')
            self.response.status = httplib.BAD_REQUEST
            return

        move_to = post_data.get('move_to', '').lower()
        if move_to and not self.CheckPerm(mr, permissions.DELETE_ISSUE):
            logging.info('user has no permission to move issue')
            self.response.status = httplib.BAD_REQUEST
            return

        config = self.services.config.GetProjectConfig(mr.cnxn, mr.project_id)

        parsed = tracker_helpers.ParseIssueRequest(mr.cnxn, post_data,
                                                   self.services, mr.errors,
                                                   mr.project_name)
        bounce_labels = (parsed.labels[:] +
                         ['-%s' % lr for lr in parsed.labels_remove])
        bounce_fields = tracker_views.MakeBounceFieldValueViews(
            parsed.fields.vals, parsed.fields.phase_vals, config)
        field_helpers.ShiftEnumFieldsIntoLabels(parsed.labels,
                                                parsed.labels_remove,
                                                parsed.fields.vals,
                                                parsed.fields.vals_remove,
                                                config)
        issue_list = self.services.issue.GetIssuesByLocalIDs(
            mr.cnxn, mr.project_id, mr.local_id_list)
        issue_phases = list(
            itertools.chain.from_iterable(issue.phases
                                          for issue in issue_list))
        phase_ids_by_name = collections.defaultdict(set)
        for phase in issue_phases:
            phase_ids_by_name[phase.name.lower()].add(phase.phase_id)
        # Note: Not all parsed phase field values will be applicable to every issue.
        # tracker_bizobj.ApplyFieldValueChanges will take care of not adding
        # phase field values to issues that don't contain the correct phase.
        field_vals = field_helpers.ParseFieldValues(
            mr.cnxn,
            self.services.user,
            parsed.fields.vals,
            parsed.fields.phase_vals,
            config,
            phase_ids_by_name=phase_ids_by_name)
        field_vals_remove = field_helpers.ParseFieldValues(
            mr.cnxn,
            self.services.user,
            parsed.fields.vals_remove,
            parsed.fields.phase_vals_remove,
            config,
            phase_ids_by_name=phase_ids_by_name)

        field_helpers.ValidateCustomFields(mr, self.services, field_vals,
                                           config, mr.errors)

        # Treat status '' as no change and explicit 'clear' as clearing the status.
        status = parsed.status
        if status == '':
            status = None
        if post_data.get('op_statusenter') == 'clear':
            status = ''

        reporter_id = mr.auth.user_id
        logging.info('bulk edit request by %s', reporter_id)

        if parsed.users.owner_id is None:
            mr.errors.owner = 'Invalid owner username'
        else:
            valid, msg = tracker_helpers.IsValidIssueOwner(
                mr.cnxn, mr.project, parsed.users.owner_id, self.services)
            if not valid:
                mr.errors.owner = msg

        if (status in config.statuses_offer_merge
                and not post_data.get('merge_into')):
            mr.errors.merge_into_id = 'Please enter a valid issue ID'

        move_to_project = None
        if move_to:
            if mr.project_name == move_to:
                mr.errors.move_to = 'The issues are already in project ' + move_to
            else:
                move_to_project = self.services.project.GetProjectByName(
                    mr.cnxn, move_to)
                if not move_to_project:
                    mr.errors.move_to = 'No such project: ' + move_to

        # Treat owner '' as no change, and explicit 'clear' as NO_USER_SPECIFIED
        owner_id = parsed.users.owner_id
        if parsed.users.owner_username == '':
            owner_id = None
        if post_data.get('op_ownerenter') == 'clear':
            owner_id = framework_constants.NO_USER_SPECIFIED

        comp_ids = tracker_helpers.LookupComponentIDs(parsed.components.paths,
                                                      config, mr.errors)
        comp_ids_remove = tracker_helpers.LookupComponentIDs(
            parsed.components.paths_remove, config, mr.errors)
        if post_data.get('op_componententer') == 'remove':
            comp_ids, comp_ids_remove = comp_ids_remove, comp_ids

        cc_ids, cc_ids_remove = parsed.users.cc_ids, parsed.users.cc_ids_remove
        if post_data.get('op_memberenter') == 'remove':
            cc_ids, cc_ids_remove = parsed.users.cc_ids_remove, parsed.users.cc_ids

        issue_list_iids = {issue.issue_id for issue in issue_list}
        if post_data.get('op_blockedonenter') == 'append':
            if issue_list_iids.intersection(parsed.blocked_on.iids):
                mr.errors.blocked_on = 'Cannot block an issue on itself.'
            blocked_on_add = parsed.blocked_on.iids
            blocked_on_remove = []
        else:
            blocked_on_add = []
            blocked_on_remove = parsed.blocked_on.iids
        if post_data.get('op_blockingenter') == 'append':
            if issue_list_iids.intersection(parsed.blocking.iids):
                mr.errors.blocking = 'Cannot block an issue on itself.'
            blocking_add = parsed.blocking.iids
            blocking_remove = []
        else:
            blocking_add = []
            blocking_remove = parsed.blocking.iids

        iids_actually_changed = []
        old_owner_ids = []
        combined_amendments = []
        merge_into_issue = None
        new_starrers = set()

        if not mr.errors.AnyErrors():
            # Because we will modify issues, load from DB rather than cache.
            issue_list = self.services.issue.GetIssuesByLocalIDs(
                mr.cnxn, mr.project_id, mr.local_id_list, use_cache=False)

            # Skip any individual issues that the user is not allowed to edit.
            editable_issues = [
                issue for issue in issue_list if permissions.CanEditIssue(
                    mr.auth.effective_ids, mr.perms, mr.project, issue)
            ]

            # Skip any restrict issues that cannot be moved
            if move_to:
                editable_issues = [
                    issue for issue in editable_issues
                    if not permissions.GetRestrictions(issue)
                ]

            # If 'Duplicate' status is specified ensure there are no permission issues
            # with the issue we want to merge with.
            if post_data.get('merge_into'):
                for issue in editable_issues:
                    _, merge_into_issue = tracker_helpers.ParseMergeFields(
                        mr.cnxn, self.services, mr.project_name, post_data,
                        parsed.status, config, issue, mr.errors)
                    if merge_into_issue:
                        merge_allowed = tracker_helpers.IsMergeAllowed(
                            merge_into_issue, mr, self.services)
                        if not merge_allowed:
                            mr.errors.merge_into_id = 'Target issue %s cannot be modified' % (
                                merge_into_issue.local_id)
                            break

                        # Update the new_starrers set.
                        new_starrers.update(
                            tracker_helpers.GetNewIssueStarrers(
                                mr.cnxn, self.services, issue.issue_id,
                                merge_into_issue.issue_id))

            # Proceed with amendments only if there are no reported errors.
            if not mr.errors.AnyErrors():
                # Sort the issues: we want them in this order so that the
                # corresponding old_owner_id are found in the same order.
                editable_issues.sort(
                    lambda i1, i2: cmp(i1.local_id, i2.local_id))

                iids_to_invalidate = set()
                rules = self.services.features.GetFilterRules(
                    mr.cnxn, config.project_id)
                predicate_asts = filterrules_helpers.ParsePredicateASTs(
                    rules, config, [])
                for issue in editable_issues:
                    old_owner_id = tracker_bizobj.GetOwnerId(issue)
                    merge_into_iid = (merge_into_issue.issue_id
                                      if merge_into_issue else None)

                    delta = tracker_bizobj.MakeIssueDelta(
                        status, owner_id, cc_ids, cc_ids_remove, comp_ids,
                        comp_ids_remove, parsed.labels, parsed.labels_remove,
                        field_vals, field_vals_remove,
                        parsed.fields.fields_clear, blocked_on_add,
                        blocked_on_remove, blocking_add, blocking_remove,
                        merge_into_iid, None)
                    amendments, _ = self.services.issue.DeltaUpdateIssue(
                        mr.cnxn,
                        self.services,
                        mr.auth.user_id,
                        mr.project_id,
                        config,
                        issue,
                        delta,
                        comment=parsed.comment,
                        iids_to_invalidate=iids_to_invalidate,
                        rules=rules,
                        predicate_asts=predicate_asts)

                    if amendments or parsed.comment:  # Avoid empty comments.
                        iids_actually_changed.append(issue.issue_id)
                        old_owner_ids.append(old_owner_id)
                        combined_amendments.extend(amendments)

                self.services.issue.InvalidateIIDs(mr.cnxn, iids_to_invalidate)
                self.services.project.UpdateRecentActivity(
                    mr.cnxn, mr.project.project_id)

                # Add new_starrers and new CCs to merge_into_issue.
                if merge_into_issue:
                    merge_into_project = self.services.project.GetProjectByName(
                        mr.cnxn, merge_into_issue.project_name)
                    tracker_helpers.AddIssueStarrers(mr.cnxn, self.services,
                                                     mr,
                                                     merge_into_issue.issue_id,
                                                     merge_into_project,
                                                     new_starrers)
                    tracker_helpers.MergeCCsAndAddCommentMultipleIssues(
                        self.services, mr, editable_issues, merge_into_issue)

                if move_to and editable_issues:
                    tracker_fulltext.UnindexIssues(
                        [issue.issue_id for issue in editable_issues])
                    for issue in editable_issues:
                        old_text_ref = 'issue %s:%s' % (issue.project_name,
                                                        issue.local_id)
                        moved_back_iids = self.services.issue.MoveIssues(
                            mr.cnxn, move_to_project, [issue],
                            self.services.user)
                        new_text_ref = 'issue %s:%s' % (issue.project_name,
                                                        issue.local_id)
                        if issue.issue_id in moved_back_iids:
                            content = 'Moved %s back to %s again.' % (
                                old_text_ref, new_text_ref)
                        else:
                            content = 'Moved %s to now be %s.' % (old_text_ref,
                                                                  new_text_ref)
                        self.services.issue.CreateIssueComment(
                            mr.cnxn,
                            issue,
                            mr.auth.user_id,
                            content,
                            amendments=[
                                tracker_bizobj.MakeProjectAmendment(
                                    move_to_project.project_name)
                            ])

                send_email = 'send_email' in post_data

                users_by_id = framework_views.MakeAllUserViews(
                    mr.cnxn, self.services.user, [owner_id], cc_ids,
                    cc_ids_remove, old_owner_ids,
                    tracker_bizobj.UsersInvolvedInAmendments(
                        combined_amendments))
                if move_to and editable_issues:
                    iids_actually_changed = [
                        issue.issue_id for issue in editable_issues
                    ]

                send_notifications.SendIssueBulkChangeNotification(
                    iids_actually_changed, mr.request.host, old_owner_ids,
                    parsed.comment, reporter_id, combined_amendments,
                    send_email, users_by_id)

        if mr.errors.AnyErrors():
            bounce_cc_parts = (
                parsed.users.cc_usernames +
                ['-%s' % ccur for ccur in parsed.users.cc_usernames_remove])
            self.PleaseCorrect(
                mr,
                initial_status=parsed.status,
                initial_owner=parsed.users.owner_username,
                initial_merge_into=post_data.get('merge_into', 0),
                initial_cc=', '.join(bounce_cc_parts),
                initial_comment=parsed.comment,
                initial_components=parsed.components.entered_str,
                labels=bounce_labels,
                fields=bounce_fields)
            return

        with mr.profiler.Phase('reindexing issues'):
            logging.info('starting reindexing')
            start = time.time()
            # Get the updated issues and index them
            issue_list = self.services.issue.GetIssuesByLocalIDs(
                mr.cnxn, mr.project_id, mr.local_id_list)
            tracker_fulltext.IndexIssues(mr.cnxn, issue_list,
                                         self.services.user,
                                         self.services.issue,
                                         self.services.config)
            logging.info('reindexing %d issues took %s sec', len(issue_list),
                         time.time() - start)

        # TODO(jrobbins): These could be put into the form action attribute.
        mr.can = int(post_data['can'])
        mr.query = post_data['q']
        mr.col_spec = post_data['colspec']
        mr.sort_spec = post_data['sort']
        mr.group_by_spec = post_data['groupby']
        mr.start = int(post_data['start'])
        mr.num = int(post_data['num'])

        # TODO(jrobbins): implement bulk=N param for a better confirmation alert.
        return tracker_helpers.FormatIssueListURL(mr,
                                                  config,
                                                  saved=len(mr.local_id_list),
                                                  ts=int(time.time()))
def convert_issue(cls, issue, mar, services):
  """Convert Monorail Issue PB to API IssuesGetInsertResponse."""

  config = services.config.GetProjectConfig(mar.cnxn, issue.project_id)
  granted_perms = tracker_bizobj.GetGrantedPerms(
      issue, mar.auth.effective_ids, config)
  issue_project = services.project.GetProject(mar.cnxn, issue.project_id)
  component_list = []
  for cd in config.component_defs:
    cid = cd.component_id
    if cid in issue.component_ids:
      component_list.append(cd.path)
  cc_list = [convert_person(p, mar.cnxn, services) for p in issue.cc_ids]
  cc_list = [p for p in cc_list if p is not None]
  field_values_list = []
  field_id_dict = {
      fd.field_id: fd.field_name for fd in config.field_defs}
  for fv in issue.field_values:
    field_name = field_id_dict.get(fv.field_id)
    if not field_name:
      logging.warning('Custom field %d of project %s does not exist',
                      fv.field_id, issue_project.project_name)
      continue
    val = None
    if fv.user_id:
      val = _get_user_email(
          services.user, mar.cnxn, fv.user_id)
    elif fv.str_value:
      val = fv.str_value
    elif fv.int_value:
      val = str(fv.int_value)
    new_fv = api_pb2_v1.FieldValue(
        fieldName=field_name,
        fieldValue=val,
        derived=fv.derived)
    field_values_list.append(new_fv)
  resp = cls(
      kind='monorail#issue',
      id=issue.local_id,
      title=issue.summary,
      summary=issue.summary,
      projectId=issue_project.project_name,
      stars=issue.star_count,
      starred=services.issue_star.IsItemStarredBy(
          mar.cnxn, issue.issue_id, mar.auth.user_id),
      status=issue.status,
      state=(api_pb2_v1.IssueState.open if
             tracker_helpers.MeansOpenInProject(
                 tracker_bizobj.GetStatus(issue), config)
             else api_pb2_v1.IssueState.closed),
      labels=issue.labels,
      components=component_list,
      author=convert_person(issue.reporter_id, mar.cnxn, services),
      owner=convert_person(issue.owner_id, mar.cnxn, services),
      cc=cc_list,
      updated=datetime.datetime.fromtimestamp(issue.modified_timestamp),
      published=datetime.datetime.fromtimestamp(issue.opened_timestamp),
      blockedOn=convert_issue_ids(issue.blocked_on_iids, mar, services),
      blocking=convert_issue_ids(issue.blocking_iids, mar, services),
      canComment=permissions.CanCommentIssue(
          mar.auth.effective_ids, mar.perms, issue_project, issue,
          granted_perms=granted_perms),
      canEdit=permissions.CanEditIssue(
          mar.auth.effective_ids, mar.perms, issue_project, issue,
          granted_perms=granted_perms),
      fieldValues=field_values_list)
  if issue.closed_timestamp > 0:
    resp.closed = datetime.datetime.fromtimestamp(issue.closed_timestamp)
  if issue.merged_into:
    resp.mergedInto=convert_issue_ids([issue.merged_into], mar, services)[0]
  if issue.owner_modified_timestamp:
    resp.owner_modified = datetime.datetime.fromtimestamp(
        issue.owner_modified_timestamp)
  if issue.status_modified_timestamp:
    resp.status_modified = datetime.datetime.fromtimestamp(
        issue.status_modified_timestamp)
  if issue.component_modified_timestamp:
    resp.component_modified = datetime.datetime.fromtimestamp(
        issue.component_modified_timestamp)
  return resp