Paul Martin
2016-04-27 c2188a840bc4153ae92112b04b2e06a90d3944aa
src/main/java/com/gitblit/tickets/ITicketService.java
@@ -36,6 +36,7 @@
import com.gitblit.IStoredSettings;
import com.gitblit.Keys;
import com.gitblit.extensions.TicketHook;
import com.gitblit.manager.IManager;
import com.gitblit.manager.INotificationManager;
import com.gitblit.manager.IPluginManager;
import com.gitblit.manager.IRepositoryManager;
@@ -47,9 +48,13 @@
import com.gitblit.models.TicketModel.Change;
import com.gitblit.models.TicketModel.Field;
import com.gitblit.models.TicketModel.Patchset;
import com.gitblit.models.TicketModel.PatchsetType;
import com.gitblit.models.TicketModel.Status;
import com.gitblit.models.TicketModel.TicketLink;
import com.gitblit.tickets.TicketIndexer.Lucene;
import com.gitblit.utils.DeepCopier;
import com.gitblit.utils.DiffUtils;
import com.gitblit.utils.JGitUtils;
import com.gitblit.utils.DiffUtils.DiffStat;
import com.gitblit.utils.StringUtils;
import com.google.common.cache.Cache;
@@ -62,7 +67,9 @@
 * @author James Moger
 *
 */
public abstract class ITicketService {
public abstract class ITicketService implements IManager {
   public static final String SETTING_UPDATE_DIFFSTATS = "migration.updateDiffstats";
   private static final String LABEL = "label";
@@ -105,6 +112,8 @@
   private final Map<String, List<TicketLabel>> labelsCache;
   private final Map<String, List<TicketMilestone>> milestonesCache;
   private final boolean updateDiffstats;
   private static class TicketKey {
      final String repository;
@@ -163,18 +172,22 @@
      this.labelsCache = new ConcurrentHashMap<String, List<TicketLabel>>();
      this.milestonesCache = new ConcurrentHashMap<String, List<TicketMilestone>>();
      this.updateDiffstats = settings.getBoolean(SETTING_UPDATE_DIFFSTATS, true);
   }
   /**
    * Start the service.
    *
    * @since 1.4.0
    */
   @Override
   public abstract ITicketService start();
   /**
    * Stop the service.
    *
    * @since 1.4.0
    */
   @Override
   public final ITicketService stop() {
      indexer.close();
      ticketsCache.invalidateAll();
@@ -185,7 +198,7 @@
   /**
    * Creates a ticket notifier.  The ticket notifier is not thread-safe!
    *
    * @since 1.4.0
    */
   public TicketNotifier createNotifier() {
      return new TicketNotifier(
@@ -200,6 +213,7 @@
    * Returns the ready status of the ticket service.
    *
    * @return true if the ticket service is ready
    * @since 1.4.0
    */
   public boolean isReady() {
      return true;
@@ -210,6 +224,7 @@
    *
    * @param repository
    * @return true if patchsets are being accepted
    * @since 1.4.0
    */
   public boolean isAcceptingNewPatchsets(RepositoryModel repository) {
      return isReady()
@@ -224,6 +239,7 @@
    *
    * @param repository
    * @return true if tickets are being accepted
    * @since 1.4.0
    */
   public boolean isAcceptingNewTickets(RepositoryModel repository) {
      return isReady()
@@ -237,9 +253,11 @@
    *
    * @param repository
    * @return true if tickets are allowed to be updated
    * @since 1.4.0
    */
   public boolean isAcceptingTicketUpdates(RepositoryModel repository) {
      return isReady()
            && repository.hasCommits
            && repository.isBare
            && !repository.isFrozen
            && !repository.isMirror;
@@ -249,6 +267,7 @@
    * Returns true if the repository has any tickets
    * @param repository
    * @return true if the repository has tickets
    * @since 1.4.0
    */
   public boolean hasTickets(RepositoryModel repository) {
      return indexer.hasTickets(repository);
@@ -256,11 +275,13 @@
   /**
    * Closes any open resources used by this service.
    * @since 1.4.0
    */
   protected abstract void close();
   /**
    * Reset all caches in the service.
    * @since 1.4.0
    */
   public final synchronized void resetCaches() {
      ticketsCache.invalidateAll();
@@ -269,10 +290,15 @@
      resetCachesImpl();
   }
   /**
    * Reset all caches in the service.
    * @since 1.4.0
    */
   protected abstract void resetCachesImpl();
   /**
    * Reset any caches for the repository in the service.
    * @since 1.4.0
    */
   public final synchronized void resetCaches(RepositoryModel repository) {
      List<TicketKey> repoKeys = new ArrayList<TicketKey>();
@@ -287,6 +313,12 @@
      resetCachesImpl(repository);
   }
   /**
    * Reset the caches for the specified repository.
    *
    * @param repository
    * @since 1.4.0
    */
   protected abstract void resetCachesImpl(RepositoryModel repository);
@@ -295,6 +327,7 @@
    *
    * @param repository
    * @return the list of labels
    * @since 1.4.0
    */
   public List<TicketLabel> getLabels(RepositoryModel repository) {
      String key = repository.name;
@@ -327,6 +360,7 @@
    * @param repository
    * @param label
    * @return a TicketLabel
    * @since 1.4.0
    */
   public TicketLabel getLabel(RepositoryModel repository, String label) {
      for (TicketLabel tl : getLabels(repository)) {
@@ -346,6 +380,7 @@
    * @param milestone
    * @param createdBy
    * @return the label
    * @since 1.4.0
    */
   public synchronized TicketLabel createLabel(RepositoryModel repository, String label, String createdBy) {
      TicketLabel lb = new TicketMilestone(label);
@@ -358,7 +393,9 @@
      } catch (IOException e) {
         log.error("failed to create label " + label + " in " + repository, e);
      } finally {
         db.close();
         if (db != null) {
            db.close();
         }
      }
      return lb;
   }
@@ -370,6 +407,7 @@
    * @param label
    * @param createdBy
    * @return true if the update was successful
    * @since 1.4.0
    */
   public synchronized boolean updateLabel(RepositoryModel repository, TicketLabel label, String createdBy) {
      Repository db = null;
@@ -383,7 +421,9 @@
      } catch (IOException e) {
         log.error("failed to update label " + label + " in " + repository, e);
      } finally {
         db.close();
         if (db != null) {
            db.close();
         }
      }
      return false;
   }
@@ -396,6 +436,7 @@
    * @param newName
    * @param createdBy
    * @return true if the rename was successful
    * @since 1.4.0
    */
   public synchronized boolean renameLabel(RepositoryModel repository, String oldName, String newName, String createdBy) {
      if (StringUtils.isEmpty(newName)) {
@@ -421,7 +462,9 @@
      } catch (IOException e) {
         log.error("failed to rename label " + oldName + " in " + repository, e);
      } finally {
         db.close();
         if (db != null) {
            db.close();
         }
      }
      return false;
   }
@@ -433,6 +476,7 @@
    * @param label
    * @param createdBy
    * @return true if the delete was successful
    * @since 1.4.0
    */
   public synchronized boolean deleteLabel(RepositoryModel repository, String label, String createdBy) {
      if (StringUtils.isEmpty(label)) {
@@ -449,7 +493,9 @@
      } catch (IOException e) {
         log.error("failed to delete label " + label + " in " + repository, e);
      } finally {
         db.close();
         if (db != null) {
            db.close();
         }
      }
      return false;
   }
@@ -459,6 +505,7 @@
    *
    * @param repository
    * @return the list of milestones
    * @since 1.4.0
    */
   public List<TicketMilestone> getMilestones(RepositoryModel repository) {
      String key = repository.name;
@@ -500,6 +547,7 @@
    * @param repository
    * @param status
    * @return the list of milestones
    * @since 1.4.0
    */
   public List<TicketMilestone> getMilestones(RepositoryModel repository, Status status) {
      List<TicketMilestone> matches = new ArrayList<TicketMilestone>();
@@ -517,13 +565,15 @@
    * @param repository
    * @param milestone
    * @return the milestone or null if it does not exist
    * @since 1.4.0
    */
   public TicketMilestone getMilestone(RepositoryModel repository, String milestone) {
      for (TicketMilestone ms : getMilestones(repository)) {
         if (ms.name.equalsIgnoreCase(milestone)) {
            TicketMilestone tm = DeepCopier.copy(ms);
            String q = QueryBuilder.q(Lucene.rid.matches(repository.getRID())).and(Lucene.milestone.matches(milestone)).build();
            ms.tickets = indexer.queryFor(q, 1, 0, Lucene.number.name(), true);
            return ms;
            tm.tickets = indexer.queryFor(q, 1, 0, Lucene.number.name(), true);
            return tm;
         }
      }
      return null;
@@ -536,6 +586,7 @@
    * @param milestone
    * @param createdBy
    * @return the milestone
    * @since 1.4.0
    */
   public synchronized TicketMilestone createMilestone(RepositoryModel repository, String milestone, String createdBy) {
      TicketMilestone ms = new TicketMilestone(milestone);
@@ -551,7 +602,9 @@
      } catch (IOException e) {
         log.error("failed to create milestone " + milestone + " in " + repository, e);
      } finally {
         db.close();
         if (db != null) {
            db.close();
         }
      }
      return ms;
   }
@@ -563,6 +616,7 @@
    * @param milestone
    * @param createdBy
    * @return true if successful
    * @since 1.4.0
    */
   public synchronized boolean updateMilestone(RepositoryModel repository, TicketMilestone milestone, String createdBy) {
      Repository db = null;
@@ -582,7 +636,9 @@
      } catch (IOException e) {
         log.error("failed to update milestone " + milestone + " in " + repository, e);
      } finally {
         db.close();
         if (db != null) {
            db.close();
         }
      }
      return false;
   }
@@ -595,44 +651,71 @@
    * @param newName
    * @param createdBy
    * @return true if successful
    * @since 1.4.0
    */
   public synchronized boolean renameMilestone(RepositoryModel repository, String oldName, String newName, String createdBy) {
      return renameMilestone(repository, oldName, newName, createdBy, true);
   }
   /**
    * Renames a milestone.
    *
    * @param repository
    * @param oldName
    * @param newName
    * @param createdBy
    * @param notifyOpenTickets
    * @return true if successful
    * @since 1.6.0
    */
   public synchronized boolean renameMilestone(RepositoryModel repository, String oldName,
         String newName, String createdBy, boolean notifyOpenTickets) {
      if (StringUtils.isEmpty(newName)) {
         throw new IllegalArgumentException("new milestone can not be empty!");
      }
      Repository db = null;
      try {
         db = repositoryManager.getRepository(repository.name);
         TicketMilestone milestone = getMilestone(repository, oldName);
         TicketMilestone tm = getMilestone(repository, oldName);
         if (tm == null) {
            return false;
         }
         StoredConfig config = db.getConfig();
         config.unsetSection(MILESTONE, oldName);
         config.setString(MILESTONE, newName, STATUS, milestone.status.name());
         config.setString(MILESTONE, newName, COLOR, milestone.color);
         if (milestone.due != null) {
            config.setString(MILESTONE, milestone.name, DUE,
                  new SimpleDateFormat(DUE_DATE_PATTERN).format(milestone.due));
         config.setString(MILESTONE, newName, STATUS, tm.status.name());
         config.setString(MILESTONE, newName, COLOR, tm.color);
         if (tm.due != null) {
            config.setString(MILESTONE, newName, DUE,
                  new SimpleDateFormat(DUE_DATE_PATTERN).format(tm.due));
         }
         config.save();
         milestonesCache.remove(repository.name);
         TicketNotifier notifier = createNotifier();
         for (QueryResult qr : milestone.tickets) {
         for (QueryResult qr : tm.tickets) {
            Change change = new Change(createdBy);
            change.setField(Field.milestone, newName);
            TicketModel ticket = updateTicket(repository, qr.number, change);
            notifier.queueMailing(ticket);
            if (notifyOpenTickets && ticket.isOpen()) {
               notifier.queueMailing(ticket);
            }
         }
         notifier.sendAll();
         if (notifyOpenTickets) {
            notifier.sendAll();
         }
         return true;
      } catch (IOException e) {
         log.error("failed to rename milestone " + oldName + " in " + repository, e);
      } finally {
         db.close();
         if (db != null) {
            db.close();
         }
      }
      return false;
   }
   /**
    * Deletes a milestone.
    *
@@ -640,13 +723,33 @@
    * @param milestone
    * @param createdBy
    * @return true if successful
    * @since 1.4.0
    */
   public synchronized boolean deleteMilestone(RepositoryModel repository, String milestone, String createdBy) {
      return deleteMilestone(repository, milestone, createdBy, true);
   }
   /**
    * Deletes a milestone.
    *
    * @param repository
    * @param milestone
    * @param createdBy
    * @param notifyOpenTickets
    * @return true if successful
    * @since 1.6.0
    */
   public synchronized boolean deleteMilestone(RepositoryModel repository, String milestone,
         String createdBy, boolean notifyOpenTickets) {
      if (StringUtils.isEmpty(milestone)) {
         throw new IllegalArgumentException("milestone can not be empty!");
      }
      Repository db = null;
      try {
         TicketMilestone tm = getMilestone(repository, milestone);
         if (tm == null) {
            return false;
         }
         db = repositoryManager.getRepository(repository.name);
         StoredConfig config = db.getConfig();
         config.unsetSection(MILESTONE, milestone);
@@ -654,20 +757,44 @@
         milestonesCache.remove(repository.name);
         TicketNotifier notifier = createNotifier();
         for (QueryResult qr : tm.tickets) {
            Change change = new Change(createdBy);
            change.setField(Field.milestone, "");
            TicketModel ticket = updateTicket(repository, qr.number, change);
            if (notifyOpenTickets && ticket.isOpen()) {
               notifier.queueMailing(ticket);
            }
         }
         if (notifyOpenTickets) {
            notifier.sendAll();
         }
         return true;
      } catch (IOException e) {
         log.error("failed to delete milestone " + milestone + " in " + repository, e);
      } finally {
         db.close();
         if (db != null) {
            db.close();
         }
      }
      return false;
   }
   /**
    * Returns the set of assigned ticket ids in the repository.
    *
    * @param repository
    * @return a set of assigned ticket ids in the repository
    * @since 1.6.0
    */
   public abstract Set<Long> getIds(RepositoryModel repository);
   /**
    * Assigns a new ticket id.
    *
    * @param repository
    * @return a new ticket id
    * @since 1.4.0
    */
   public abstract long assignNewId(RepositoryModel repository);
@@ -677,6 +804,7 @@
    * @param repository
    * @param ticketId
    * @return true if the ticket exists
    * @since 1.4.0
    */
   public abstract boolean hasTicket(RepositoryModel repository, long ticketId);
@@ -685,6 +813,7 @@
    *
    * @param repository
    * @return all tickets
    * @since 1.4.0
    */
   public List<TicketModel> getTickets(RepositoryModel repository) {
      return getTickets(repository, null);
@@ -700,6 +829,7 @@
    * @param filter
    *            optional issue filter to only return matching results
    * @return a list of tickets
    * @since 1.4.0
    */
   public abstract List<TicketModel> getTickets(RepositoryModel repository, TicketFilter filter);
@@ -709,31 +839,35 @@
    * @param repository
    * @param ticketId
    * @return a ticket, if it exists, otherwise null
    * @since 1.4.0
    */
   public final TicketModel getTicket(RepositoryModel repository, long ticketId) {
      TicketKey key = new TicketKey(repository, ticketId);
      TicketModel ticket = ticketsCache.getIfPresent(key);
      // if ticket not cached
      if (ticket == null) {
         // load & cache ticket
         //load ticket
         ticket = getTicketImpl(repository, ticketId);
         if (ticket.hasPatchsets()) {
            Repository r = repositoryManager.getRepository(repository.name);
            try {
               Patchset patchset = ticket.getCurrentPatchset();
               DiffStat diffStat = DiffUtils.getDiffStat(r, patchset.base, patchset.tip);
               // diffstat could be null if we have ticket data without the
               // commit objects.  e.g. ticket replication without repo
               // mirroring
               if (diffStat != null) {
                  ticket.insertions = diffStat.getInsertions();
                  ticket.deletions = diffStat.getDeletions();
               }
            } finally {
               r.close();
            }
         }
         // if ticket exists
         if (ticket != null) {
            if (ticket.hasPatchsets() && updateDiffstats) {
               Repository r = repositoryManager.getRepository(repository.name);
               try {
                  Patchset patchset = ticket.getCurrentPatchset();
                  DiffStat diffStat = DiffUtils.getDiffStat(r, patchset.base, patchset.tip);
                  // diffstat could be null if we have ticket data without the
                  // commit objects.  e.g. ticket replication without repo
                  // mirroring
                  if (diffStat != null) {
                     ticket.insertions = diffStat.getInsertions();
                     ticket.deletions = diffStat.getDeletions();
                  }
               } finally {
                  r.close();
               }
            }
            //cache ticket
            ticketsCache.put(key, ticket);
         }
      }
@@ -746,14 +880,43 @@
    * @param repository
    * @param ticketId
    * @return a ticket, if it exists, otherwise null
    * @since 1.4.0
    */
   protected abstract TicketModel getTicketImpl(RepositoryModel repository, long ticketId);
   /**
    * Returns the journal used to build a ticket.
    *
    * @param repository
    * @param ticketId
    * @return the journal for the ticket, if it exists, otherwise null
    * @since 1.6.0
    */
   public final List<Change> getJournal(RepositoryModel repository, long ticketId) {
      if (hasTicket(repository, ticketId)) {
         List<Change> journal = getJournalImpl(repository, ticketId);
         return journal;
      }
      return null;
   }
   /**
    * Retrieves the ticket journal.
    *
    * @param repository
    * @param ticketId
    * @return a ticket, if it exists, otherwise null
    * @since 1.6.0
    */
   protected abstract List<Change> getJournalImpl(RepositoryModel repository, long ticketId);
   /**
    * Get the ticket url
    *
    * @param ticket
    * @return the ticket url
    * @since 1.4.0
    */
   public String getTicketUrl(TicketModel ticket) {
      final String canonicalUrl = settings.getString(Keys.web.canonicalUrl, "https://localhost:8443");
@@ -767,6 +930,7 @@
    * @param base
    * @param tip
    * @return the compare url
    * @since 1.4.0
    */
   public String getCompareUrl(TicketModel ticket, String base, String tip) {
      final String canonicalUrl = settings.getString(Keys.web.canonicalUrl, "https://localhost:8443");
@@ -778,6 +942,7 @@
    * Returns true if attachments are supported.
    *
    * @return true if attachments are supported
    * @since 1.4.0
    */
   public abstract boolean supportsAttachments();
@@ -788,6 +953,7 @@
    * @param ticketId
    * @param filename
    * @return an attachment, if found, null otherwise
    * @since 1.4.0
    */
   public abstract Attachment getAttachment(RepositoryModel repository, long ticketId, String filename);
@@ -799,6 +965,7 @@
    * @param repository
    * @param change
    * @return true if successful
    * @since 1.4.0
    */
   public TicketModel createTicket(RepositoryModel repository, Change change) {
      return createTicket(repository, 0L, change);
@@ -813,6 +980,7 @@
    * @param ticketId (if <=0 the ticket id will be assigned)
    * @param change
    * @return true if successful
    * @since 1.4.0
    */
   public TicketModel createTicket(RepositoryModel repository, long ticketId, Change change) {
@@ -855,12 +1023,13 @@
   }
   /**
    * Updates a ticket.
    * Updates a ticket and promotes pending links into references.
    *
    * @param repository
    * @param ticketId
    * @param ticketId, or 0 to action pending links in general
    * @param change
    * @return the ticket model if successful
    * @return the ticket model if successful, null if failure or using 0 ticketId
    * @since 1.4.0
    */
   public final TicketModel updateTicket(RepositoryModel repository, long ticketId, Change change) {
      if (change == null) {
@@ -871,34 +1040,85 @@
         throw new RuntimeException("must specify a change author!");
      }
      TicketKey key = new TicketKey(repository, ticketId);
      ticketsCache.invalidate(key);
      boolean success = commitChangeImpl(repository, ticketId, change);
      if (success) {
         TicketModel ticket = getTicket(repository, ticketId);
         ticketsCache.put(key, ticket);
         indexer.index(ticket);
         // call the ticket hooks
         if (pluginManager != null) {
            for (TicketHook hook : pluginManager.getExtensions(TicketHook.class)) {
               try {
                  hook.onUpdateTicket(ticket, change);
               } catch (Exception e) {
                  log.error("Failed to execute extension", e);
      boolean success = true;
      TicketModel ticket = null;
      if (ticketId > 0) {
         TicketKey key = new TicketKey(repository, ticketId);
         ticketsCache.invalidate(key);
         success = commitChangeImpl(repository, ticketId, change);
         if (success) {
            ticket = getTicket(repository, ticketId);
            ticketsCache.put(key, ticket);
            indexer.index(ticket);
            // call the ticket hooks
            if (pluginManager != null) {
               for (TicketHook hook : pluginManager.getExtensions(TicketHook.class)) {
                  try {
                     hook.onUpdateTicket(ticket, change);
                  } catch (Exception e) {
                     log.error("Failed to execute extension", e);
                  }
               }
            }
         }
         return ticket;
      }
      return null;
      if (success) {
         //Now that the ticket has been successfully persisted add references to this ticket from linked tickets
         if (change.hasPendingLinks()) {
            for (TicketLink link : change.pendingLinks) {
               TicketModel linkedTicket = getTicket(repository, link.targetTicketId);
               Change dstChange = null;
               //Ignore if not available or self reference
               if (linkedTicket != null && link.targetTicketId != ticketId) {
                  dstChange = new Change(change.author, change.date);
                  switch (link.action) {
                     case Comment: {
                        if (ticketId == 0) {
                           throw new RuntimeException("must specify a ticket when linking a comment!");
                        }
                        dstChange.referenceTicket(ticketId, change.comment.id);
                     } break;
                     case Commit: {
                        dstChange.referenceCommit(link.hash);
                     } break;
                     default: {
                        throw new RuntimeException(
                              String.format("must add persist logic for link of type %s", link.action));
                     }
                  }
               }
               if (dstChange != null) {
                  //If not deleted then remain null in journal
                  if (link.isDelete) {
                     dstChange.reference.deleted = true;
                  }
                  if (updateTicket(repository, link.targetTicketId, dstChange) != null) {
                     link.success = true;
                  }
               }
            }
         }
      }
      return ticket;
   }
   /**
    * Deletes all tickets in every repository.
    *
    * @return true if successful
    * @since 1.4.0
    */
   public boolean deleteAll() {
      List<String> repositories = repositoryManager.getRepositoryList();
@@ -921,6 +1141,7 @@
    * Deletes all tickets in the specified repository.
    * @param repository
    * @return true if succesful
    * @since 1.4.0
    */
   public boolean deleteAll(RepositoryModel repository) {
      boolean success = deleteAllImpl(repository);
@@ -932,6 +1153,12 @@
      return success;
   }
   /**
    * Delete all tickets for the specified repository.
    * @param repository
    * @return true if successful
    * @since 1.4.0
    */
   protected abstract boolean deleteAllImpl(RepositoryModel repository);
   /**
@@ -940,6 +1167,7 @@
    * @param oldRepositoryName
    * @param newRepositoryName
    * @return true if successful
    * @since 1.4.0
    */
   public boolean rename(RepositoryModel oldRepository, RepositoryModel newRepository) {
      if (renameImpl(oldRepository, newRepository)) {
@@ -951,6 +1179,14 @@
      return false;
   }
   /**
    * Renames a repository.
    *
    * @param oldRepository
    * @param newRepository
    * @return true if successful
    * @since 1.4.0
    */
   protected abstract boolean renameImpl(RepositoryModel oldRepository, RepositoryModel newRepository);
   /**
@@ -960,6 +1196,7 @@
    * @param ticketId
    * @param deletedBy
    * @return true if successful
    * @since 1.4.0
    */
   public boolean deleteTicket(RepositoryModel repository, long ticketId, String deletedBy) {
      TicketModel ticket = getTicket(repository, ticketId);
@@ -981,6 +1218,7 @@
    * @param ticket
    * @param deletedBy
    * @return true if successful
    * @since 1.4.0
    */
   protected abstract boolean deleteTicketImpl(RepositoryModel repository, TicketModel ticket, String deletedBy);
@@ -996,6 +1234,7 @@
    * @param comment
    *            the revised comment
    * @return the revised ticket if the change was successful
    * @since 1.4.0
    */
   public final TicketModel updateComment(TicketModel ticket, String commentId,
         String updatedBy, String comment) {
@@ -1016,6 +1255,7 @@
    * @param deletedBy
    *          the user deleting the comment
    * @return the revised ticket if the deletion was successful
    * @since 1.4.0
    */
   public final TicketModel deleteComment(TicketModel ticket, String commentId, String deletedBy) {
      Change deletion = new Change(deletedBy);
@@ -1026,6 +1266,39 @@
      TicketModel revisedTicket = updateTicket(repository, ticket.number, deletion);
      return revisedTicket;
   }
   /**
    * Deletes a patchset from a ticket.
    *
    * @param ticket
    * @param patchset
    *            the patchset to delete (should be the highest revision)
    * @param userName
    *          the user deleting the commit
    * @return the revised ticket if the deletion was successful
    * @since 1.8.0
    */
   public final TicketModel deletePatchset(TicketModel ticket, Patchset patchset, String userName) {
      Change deletion = new Change(userName);
      deletion.patchset = new Patchset();
      deletion.patchset.number = patchset.number;
      deletion.patchset.rev = patchset.rev;
      deletion.patchset.type = PatchsetType.Delete;
      //Find and delete references to tickets by the removed commits
      List<TicketLink> patchsetTicketLinks = JGitUtils.identifyTicketsBetweenCommits(
            repositoryManager.getRepository(ticket.repository),
            settings, patchset.base, patchset.tip);
      for (TicketLink link : patchsetTicketLinks) {
         link.isDelete = true;
      }
      deletion.pendingLinks = patchsetTicketLinks;
      RepositoryModel repositoryModel = repositoryManager.getRepositoryModel(ticket.repository);
      TicketModel revisedTicket = updateTicket(repositoryModel, ticket.number, deletion);
      return revisedTicket;
   }
   /**
    * Commit a ticket change to the repository.
@@ -1034,6 +1307,7 @@
    * @param ticketId
    * @param change
    * @return true, if the change was committed
    * @since 1.4.0
    */
   protected abstract boolean commitChangeImpl(RepositoryModel repository, long ticketId, Change change);
@@ -1048,6 +1322,7 @@
    * @param page
    * @param pageSize
    * @return a list of matching tickets
    * @since 1.4.0
    */
   public List<QueryResult> searchFor(RepositoryModel repository, String text, int page, int pageSize) {
      return indexer.searchFor(repository, text, page, pageSize);
@@ -1062,6 +1337,7 @@
    * @param sortBy
    * @param descending
    * @return a list of matching tickets or an empty list
    * @since 1.4.0
    */
   public List<QueryResult> queryFor(String query, int page, int pageSize, String sortBy, boolean descending) {
      return indexer.queryFor(query, page, pageSize, sortBy, descending);
@@ -1070,6 +1346,7 @@
   /**
    * Destroys an existing index and reindexes all tickets.
    * This operation may be expensive and time-consuming.
    * @since 1.4.0
    */
   public void reindex() {
      long start = System.nanoTime();
@@ -1096,6 +1373,7 @@
   /**
    * Destroys any existing index and reindexes all tickets.
    * This operation may be expensive and time-consuming.
    * @since 1.4.0
    */
   public void reindex(RepositoryModel repository) {
      long start = System.nanoTime();
@@ -1113,6 +1391,7 @@
    * of ticket updates, namely merging from the web ui.
    *
    * @param runnable
    * @since 1.4.0
    */
   public synchronized void exec(Runnable runnable) {
      runnable.run();