Paul Martin
2016-04-06 2fca824e349f5fecbf71d940c4521644e92cb0dd
commit | author | age
5e3521 1 /*
JM 2  * Copyright 2013 gitblit.com.
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *     http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 package com.gitblit.tickets;
17
18 import java.io.IOException;
19 import java.text.MessageFormat;
20 import java.text.ParseException;
21 import java.text.SimpleDateFormat;
22 import java.util.ArrayList;
23 import java.util.BitSet;
24 import java.util.Collections;
25 import java.util.List;
26 import java.util.Map;
27 import java.util.Set;
28 import java.util.concurrent.ConcurrentHashMap;
29 import java.util.concurrent.TimeUnit;
30
31 import org.eclipse.jgit.lib.Repository;
32 import org.eclipse.jgit.lib.StoredConfig;
33 import org.slf4j.Logger;
34 import org.slf4j.LoggerFactory;
35
36 import com.gitblit.IStoredSettings;
37 import com.gitblit.Keys;
ba5670 38 import com.gitblit.extensions.TicketHook;
c42032 39 import com.gitblit.manager.IManager;
5e3521 40 import com.gitblit.manager.INotificationManager;
ba5670 41 import com.gitblit.manager.IPluginManager;
5e3521 42 import com.gitblit.manager.IRepositoryManager;
JM 43 import com.gitblit.manager.IRuntimeManager;
44 import com.gitblit.manager.IUserManager;
45 import com.gitblit.models.RepositoryModel;
46 import com.gitblit.models.TicketModel;
47 import com.gitblit.models.TicketModel.Attachment;
48 import com.gitblit.models.TicketModel.Change;
49 import com.gitblit.models.TicketModel.Field;
50 import com.gitblit.models.TicketModel.Patchset;
cd7e4f 51 import com.gitblit.models.TicketModel.PatchsetType;
5e3521 52 import com.gitblit.models.TicketModel.Status;
JM 53 import com.gitblit.tickets.TicketIndexer.Lucene;
ce048e 54 import com.gitblit.utils.DeepCopier;
5e3521 55 import com.gitblit.utils.DiffUtils;
JM 56 import com.gitblit.utils.DiffUtils.DiffStat;
57 import com.gitblit.utils.StringUtils;
58 import com.google.common.cache.Cache;
59 import com.google.common.cache.CacheBuilder;
60
61 /**
62  * Abstract parent class of a ticket service that stubs out required methods
63  * and transparently handles Lucene indexing.
64  *
65  * @author James Moger
66  *
67  */
c42032 68 public abstract class ITicketService implements IManager {
5e3521 69
4d81c9 70     public static final String SETTING_UPDATE_DIFFSTATS = "migration.updateDiffstats";
JM 71
5e3521 72     private static final String LABEL = "label";
JM 73
74     private static final String MILESTONE = "milestone";
75
76     private static final String STATUS = "status";
77
78     private static final String COLOR = "color";
79
80     private static final String DUE = "due";
81
82     private static final String DUE_DATE_PATTERN = "yyyy-MM-dd";
83
84     /**
85      * Object filter interface to querying against all available ticket models.
86      */
87     public interface TicketFilter {
88
89         boolean accept(TicketModel ticket);
90     }
91
92     protected final Logger log;
93
94     protected final IStoredSettings settings;
95
96     protected final IRuntimeManager runtimeManager;
97
98     protected final INotificationManager notificationManager;
99
100     protected final IUserManager userManager;
101
102     protected final IRepositoryManager repositoryManager;
103
ba5670 104     protected final IPluginManager pluginManager;
JM 105
5e3521 106     protected final TicketIndexer indexer;
JM 107
108     private final Cache<TicketKey, TicketModel> ticketsCache;
109
110     private final Map<String, List<TicketLabel>> labelsCache;
111
112     private final Map<String, List<TicketMilestone>> milestonesCache;
4d81c9 113
JM 114     private final boolean updateDiffstats;
5e3521 115
JM 116     private static class TicketKey {
117         final String repository;
118         final long ticketId;
119
120         TicketKey(RepositoryModel repository, long ticketId) {
121             this.repository = repository.name;
122             this.ticketId = ticketId;
123         }
124
125         @Override
126         public int hashCode() {
127             return (repository + ticketId).hashCode();
128         }
129
130         @Override
131         public boolean equals(Object o) {
132             if (o instanceof TicketKey) {
133                 return o.hashCode() == hashCode();
134             }
135             return false;
136         }
137
138         @Override
139         public String toString() {
140             return repository + ":" + ticketId;
141         }
142     }
143
144
145     /**
146      * Creates a ticket service.
147      */
148     public ITicketService(
149             IRuntimeManager runtimeManager,
ba5670 150             IPluginManager pluginManager,
5e3521 151             INotificationManager notificationManager,
JM 152             IUserManager userManager,
153             IRepositoryManager repositoryManager) {
154
155         this.log = LoggerFactory.getLogger(getClass());
156         this.settings = runtimeManager.getSettings();
157         this.runtimeManager = runtimeManager;
ba5670 158         this.pluginManager = pluginManager;
5e3521 159         this.notificationManager = notificationManager;
JM 160         this.userManager = userManager;
161         this.repositoryManager = repositoryManager;
162
163         this.indexer = new TicketIndexer(runtimeManager);
164
165         CacheBuilder<Object, Object> cb = CacheBuilder.newBuilder();
166         this.ticketsCache = cb
167                 .maximumSize(1000)
168                 .expireAfterAccess(30, TimeUnit.MINUTES)
169                 .build();
170
171         this.labelsCache = new ConcurrentHashMap<String, List<TicketLabel>>();
172         this.milestonesCache = new ConcurrentHashMap<String, List<TicketMilestone>>();
4d81c9 173
JM 174         this.updateDiffstats = settings.getBoolean(SETTING_UPDATE_DIFFSTATS, true);
5e3521 175     }
JM 176
177     /**
178      * Start the service.
3a6499 179      * @since 1.4.0
5e3521 180      */
c42032 181     @Override
5e3521 182     public abstract ITicketService start();
JM 183
184     /**
185      * Stop the service.
3a6499 186      * @since 1.4.0
5e3521 187      */
c42032 188     @Override
5e3521 189     public final ITicketService stop() {
JM 190         indexer.close();
191         ticketsCache.invalidateAll();
192         repositoryManager.closeAll();
193         close();
194         return this;
195     }
196
197     /**
198      * Creates a ticket notifier.  The ticket notifier is not thread-safe!
3a6499 199      * @since 1.4.0
5e3521 200      */
JM 201     public TicketNotifier createNotifier() {
202         return new TicketNotifier(
203                 runtimeManager,
204                 notificationManager,
205                 userManager,
206                 repositoryManager,
207                 this);
208     }
209
210     /**
211      * Returns the ready status of the ticket service.
212      *
213      * @return true if the ticket service is ready
3a6499 214      * @since 1.4.0
5e3521 215      */
JM 216     public boolean isReady() {
217         return true;
218     }
219
220     /**
221      * Returns true if the new patchsets can be accepted for this repository.
222      *
223      * @param repository
224      * @return true if patchsets are being accepted
3a6499 225      * @since 1.4.0
5e3521 226      */
JM 227     public boolean isAcceptingNewPatchsets(RepositoryModel repository) {
228         return isReady()
229                 && settings.getBoolean(Keys.tickets.acceptNewPatchsets, true)
230                 && repository.acceptNewPatchsets
231                 && isAcceptingTicketUpdates(repository);
232     }
233
234     /**
235      * Returns true if new tickets can be manually created for this repository.
236      * This is separate from accepting patchsets.
237      *
238      * @param repository
239      * @return true if tickets are being accepted
3a6499 240      * @since 1.4.0
5e3521 241      */
JM 242     public boolean isAcceptingNewTickets(RepositoryModel repository) {
243         return isReady()
244                 && settings.getBoolean(Keys.tickets.acceptNewTickets, true)
245                 && repository.acceptNewTickets
246                 && isAcceptingTicketUpdates(repository);
247     }
248
249     /**
250      * Returns true if ticket updates are allowed for this repository.
251      *
252      * @param repository
253      * @return true if tickets are allowed to be updated
3a6499 254      * @since 1.4.0
5e3521 255      */
JM 256     public boolean isAcceptingTicketUpdates(RepositoryModel repository) {
257         return isReady()
c58ecd 258                 && repository.hasCommits
5e3521 259                 && repository.isBare
JM 260                 && !repository.isFrozen
261                 && !repository.isMirror;
262     }
263
264     /**
265      * Returns true if the repository has any tickets
266      * @param repository
267      * @return true if the repository has tickets
3a6499 268      * @since 1.4.0
5e3521 269      */
JM 270     public boolean hasTickets(RepositoryModel repository) {
271         return indexer.hasTickets(repository);
272     }
273
274     /**
275      * Closes any open resources used by this service.
3a6499 276      * @since 1.4.0
5e3521 277      */
JM 278     protected abstract void close();
279
280     /**
281      * Reset all caches in the service.
3a6499 282      * @since 1.4.0
5e3521 283      */
JM 284     public final synchronized void resetCaches() {
285         ticketsCache.invalidateAll();
286         labelsCache.clear();
287         milestonesCache.clear();
288         resetCachesImpl();
289     }
290
3a6499 291     /**
JM 292      * Reset all caches in the service.
293      * @since 1.4.0
294      */
5e3521 295     protected abstract void resetCachesImpl();
JM 296
297     /**
298      * Reset any caches for the repository in the service.
3a6499 299      * @since 1.4.0
5e3521 300      */
JM 301     public final synchronized void resetCaches(RepositoryModel repository) {
302         List<TicketKey> repoKeys = new ArrayList<TicketKey>();
303         for (TicketKey key : ticketsCache.asMap().keySet()) {
304             if (key.repository.equals(repository.name)) {
305                 repoKeys.add(key);
306             }
307         }
308         ticketsCache.invalidateAll(repoKeys);
309         labelsCache.remove(repository.name);
310         milestonesCache.remove(repository.name);
311         resetCachesImpl(repository);
312     }
313
3a6499 314     /**
JM 315      * Reset the caches for the specified repository.
316      *
317      * @param repository
318      * @since 1.4.0
319      */
5e3521 320     protected abstract void resetCachesImpl(RepositoryModel repository);
JM 321
322
323     /**
324      * Returns the list of labels for the repository.
325      *
326      * @param repository
327      * @return the list of labels
3a6499 328      * @since 1.4.0
5e3521 329      */
JM 330     public List<TicketLabel> getLabels(RepositoryModel repository) {
331         String key = repository.name;
332         if (labelsCache.containsKey(key)) {
333             return labelsCache.get(key);
334         }
335         List<TicketLabel> list = new ArrayList<TicketLabel>();
336         Repository db = repositoryManager.getRepository(repository.name);
337         try {
338             StoredConfig config = db.getConfig();
339             Set<String> names = config.getSubsections(LABEL);
340             for (String name : names) {
341                 TicketLabel label = new TicketLabel(name);
342                 label.color = config.getString(LABEL, name, COLOR);
343                 list.add(label);
344             }
345             labelsCache.put(key,  Collections.unmodifiableList(list));
346         } catch (Exception e) {
347             log.error("invalid tickets settings for " + repository, e);
348         } finally {
349             db.close();
350         }
351         return list;
352     }
353
354     /**
355      * Returns a TicketLabel object for a given label.  If the label is not
356      * found, a ticket label object is created.
357      *
358      * @param repository
359      * @param label
360      * @return a TicketLabel
3a6499 361      * @since 1.4.0
5e3521 362      */
JM 363     public TicketLabel getLabel(RepositoryModel repository, String label) {
364         for (TicketLabel tl : getLabels(repository)) {
365             if (tl.name.equalsIgnoreCase(label)) {
366                 String q = QueryBuilder.q(Lucene.rid.matches(repository.getRID())).and(Lucene.labels.matches(label)).build();
367                 tl.tickets = indexer.queryFor(q, 1, 0, Lucene.number.name(), true);
368                 return tl;
369             }
370         }
371         return new TicketLabel(label);
372     }
373
374     /**
375      * Creates a label.
376      *
377      * @param repository
378      * @param milestone
379      * @param createdBy
380      * @return the label
3a6499 381      * @since 1.4.0
5e3521 382      */
JM 383     public synchronized TicketLabel createLabel(RepositoryModel repository, String label, String createdBy) {
384         TicketLabel lb = new TicketMilestone(label);
385         Repository db = null;
386         try {
387             db = repositoryManager.getRepository(repository.name);
388             StoredConfig config = db.getConfig();
389             config.setString(LABEL, label, COLOR, lb.color);
390             config.save();
391         } catch (IOException e) {
392             log.error("failed to create label " + label + " in " + repository, e);
393         } finally {
9ee8b1 394             if (db != null) {
JM 395                 db.close();
396             }
5e3521 397         }
JM 398         return lb;
399     }
400
401     /**
402      * Updates a label.
403      *
404      * @param repository
405      * @param label
406      * @param createdBy
407      * @return true if the update was successful
3a6499 408      * @since 1.4.0
5e3521 409      */
JM 410     public synchronized boolean updateLabel(RepositoryModel repository, TicketLabel label, String createdBy) {
411         Repository db = null;
412         try {
413             db = repositoryManager.getRepository(repository.name);
414             StoredConfig config = db.getConfig();
415             config.setString(LABEL, label.name, COLOR, label.color);
416             config.save();
417
418             return true;
419         } catch (IOException e) {
420             log.error("failed to update label " + label + " in " + repository, e);
421         } finally {
9ee8b1 422             if (db != null) {
JM 423                 db.close();
424             }
5e3521 425         }
JM 426         return false;
427     }
428
429     /**
430      * Renames a label.
431      *
432      * @param repository
433      * @param oldName
434      * @param newName
435      * @param createdBy
436      * @return true if the rename was successful
3a6499 437      * @since 1.4.0
5e3521 438      */
JM 439     public synchronized boolean renameLabel(RepositoryModel repository, String oldName, String newName, String createdBy) {
440         if (StringUtils.isEmpty(newName)) {
441             throw new IllegalArgumentException("new label can not be empty!");
442         }
443         Repository db = null;
444         try {
445             db = repositoryManager.getRepository(repository.name);
446             TicketLabel label = getLabel(repository, oldName);
447             StoredConfig config = db.getConfig();
448             config.unsetSection(LABEL, oldName);
449             config.setString(LABEL, newName, COLOR, label.color);
450             config.save();
451
452             for (QueryResult qr : label.tickets) {
453                 Change change = new Change(createdBy);
454                 change.unlabel(oldName);
455                 change.label(newName);
456                 updateTicket(repository, qr.number, change);
457             }
458
459             return true;
460         } catch (IOException e) {
461             log.error("failed to rename label " + oldName + " in " + repository, e);
462         } finally {
9ee8b1 463             if (db != null) {
JM 464                 db.close();
465             }
5e3521 466         }
JM 467         return false;
468     }
469
470     /**
471      * Deletes a label.
472      *
473      * @param repository
474      * @param label
475      * @param createdBy
476      * @return true if the delete was successful
3a6499 477      * @since 1.4.0
5e3521 478      */
JM 479     public synchronized boolean deleteLabel(RepositoryModel repository, String label, String createdBy) {
480         if (StringUtils.isEmpty(label)) {
481             throw new IllegalArgumentException("label can not be empty!");
482         }
483         Repository db = null;
484         try {
485             db = repositoryManager.getRepository(repository.name);
486             StoredConfig config = db.getConfig();
487             config.unsetSection(LABEL, label);
488             config.save();
489
490             return true;
491         } catch (IOException e) {
492             log.error("failed to delete label " + label + " in " + repository, e);
493         } finally {
9ee8b1 494             if (db != null) {
JM 495                 db.close();
496             }
5e3521 497         }
JM 498         return false;
499     }
500
501     /**
502      * Returns the list of milestones for the repository.
503      *
504      * @param repository
505      * @return the list of milestones
3a6499 506      * @since 1.4.0
5e3521 507      */
JM 508     public List<TicketMilestone> getMilestones(RepositoryModel repository) {
509         String key = repository.name;
510         if (milestonesCache.containsKey(key)) {
511             return milestonesCache.get(key);
512         }
513         List<TicketMilestone> list = new ArrayList<TicketMilestone>();
514         Repository db = repositoryManager.getRepository(repository.name);
515         try {
516             StoredConfig config = db.getConfig();
517             Set<String> names = config.getSubsections(MILESTONE);
518             for (String name : names) {
519                 TicketMilestone milestone = new TicketMilestone(name);
520                 milestone.status = Status.fromObject(config.getString(MILESTONE, name, STATUS), milestone.status);
521                 milestone.color = config.getString(MILESTONE, name, COLOR);
522                 String due = config.getString(MILESTONE, name, DUE);
523                 if (!StringUtils.isEmpty(due)) {
524                     try {
525                         milestone.due = new SimpleDateFormat(DUE_DATE_PATTERN).parse(due);
526                     } catch (ParseException e) {
527                         log.error("failed to parse {} milestone {} due date \"{}\"",
528                                 new Object [] { repository, name, due });
529                     }
530                 }
531                 list.add(milestone);
532             }
533             milestonesCache.put(key, Collections.unmodifiableList(list));
534         } catch (Exception e) {
535             log.error("invalid tickets settings for " + repository, e);
536         } finally {
537             db.close();
538         }
539         return list;
540     }
541
542     /**
543      * Returns the list of milestones for the repository that match the status.
544      *
545      * @param repository
546      * @param status
547      * @return the list of milestones
3a6499 548      * @since 1.4.0
5e3521 549      */
JM 550     public List<TicketMilestone> getMilestones(RepositoryModel repository, Status status) {
551         List<TicketMilestone> matches = new ArrayList<TicketMilestone>();
552         for (TicketMilestone milestone : getMilestones(repository)) {
553             if (status == milestone.status) {
554                 matches.add(milestone);
555             }
556         }
557         return matches;
558     }
559
560     /**
561      * Returns the specified milestone or null if the milestone does not exist.
562      *
563      * @param repository
564      * @param milestone
565      * @return the milestone or null if it does not exist
3a6499 566      * @since 1.4.0
5e3521 567      */
JM 568     public TicketMilestone getMilestone(RepositoryModel repository, String milestone) {
569         for (TicketMilestone ms : getMilestones(repository)) {
570             if (ms.name.equalsIgnoreCase(milestone)) {
ce048e 571                 TicketMilestone tm = DeepCopier.copy(ms);
5e3521 572                 String q = QueryBuilder.q(Lucene.rid.matches(repository.getRID())).and(Lucene.milestone.matches(milestone)).build();
ce048e 573                 tm.tickets = indexer.queryFor(q, 1, 0, Lucene.number.name(), true);
JM 574                 return tm;
5e3521 575             }
JM 576         }
577         return null;
578     }
579
580     /**
581      * Creates a milestone.
582      *
583      * @param repository
584      * @param milestone
585      * @param createdBy
586      * @return the milestone
3a6499 587      * @since 1.4.0
5e3521 588      */
JM 589     public synchronized TicketMilestone createMilestone(RepositoryModel repository, String milestone, String createdBy) {
590         TicketMilestone ms = new TicketMilestone(milestone);
591         Repository db = null;
592         try {
593             db = repositoryManager.getRepository(repository.name);
594             StoredConfig config = db.getConfig();
595             config.setString(MILESTONE, milestone, STATUS, ms.status.name());
596             config.setString(MILESTONE, milestone, COLOR, ms.color);
597             config.save();
598
599             milestonesCache.remove(repository.name);
600         } catch (IOException e) {
601             log.error("failed to create milestone " + milestone + " in " + repository, e);
602         } finally {
9ee8b1 603             if (db != null) {
JM 604                 db.close();
605             }
5e3521 606         }
JM 607         return ms;
608     }
609
610     /**
611      * Updates a milestone.
612      *
613      * @param repository
614      * @param milestone
615      * @param createdBy
616      * @return true if successful
3a6499 617      * @since 1.4.0
5e3521 618      */
JM 619     public synchronized boolean updateMilestone(RepositoryModel repository, TicketMilestone milestone, String createdBy) {
620         Repository db = null;
621         try {
622             db = repositoryManager.getRepository(repository.name);
623             StoredConfig config = db.getConfig();
624             config.setString(MILESTONE, milestone.name, STATUS, milestone.status.name());
625             config.setString(MILESTONE, milestone.name, COLOR, milestone.color);
626             if (milestone.due != null) {
627                 config.setString(MILESTONE, milestone.name, DUE,
628                         new SimpleDateFormat(DUE_DATE_PATTERN).format(milestone.due));
629             }
630             config.save();
631
632             milestonesCache.remove(repository.name);
633             return true;
634         } catch (IOException e) {
635             log.error("failed to update milestone " + milestone + " in " + repository, e);
636         } finally {
9ee8b1 637             if (db != null) {
JM 638                 db.close();
639             }
5e3521 640         }
JM 641         return false;
642     }
643
644     /**
645      * Renames a milestone.
646      *
647      * @param repository
648      * @param oldName
649      * @param newName
650      * @param createdBy
651      * @return true if successful
3a6499 652      * @since 1.4.0
5e3521 653      */
JM 654     public synchronized boolean renameMilestone(RepositoryModel repository, String oldName, String newName, String createdBy) {
ce048e 655         return renameMilestone(repository, oldName, newName, createdBy, true);
JM 656     }
019958 657
ce048e 658     /**
JM 659      * Renames a milestone.
660      *
661      * @param repository
662      * @param oldName
663      * @param newName
664      * @param createdBy
667163 665      * @param notifyOpenTickets
ce048e 666      * @return true if successful
JM 667      * @since 1.6.0
668      */
667163 669     public synchronized boolean renameMilestone(RepositoryModel repository, String oldName,
JM 670             String newName, String createdBy, boolean notifyOpenTickets) {
5e3521 671         if (StringUtils.isEmpty(newName)) {
JM 672             throw new IllegalArgumentException("new milestone can not be empty!");
673         }
674         Repository db = null;
675         try {
676             db = repositoryManager.getRepository(repository.name);
270e9e 677             TicketMilestone tm = getMilestone(repository, oldName);
JM 678             if (tm == null) {
679                 return false;
680             }
5e3521 681             StoredConfig config = db.getConfig();
JM 682             config.unsetSection(MILESTONE, oldName);
270e9e 683             config.setString(MILESTONE, newName, STATUS, tm.status.name());
JM 684             config.setString(MILESTONE, newName, COLOR, tm.color);
685             if (tm.due != null) {
ce048e 686                 config.setString(MILESTONE, newName, DUE,
270e9e 687                         new SimpleDateFormat(DUE_DATE_PATTERN).format(tm.due));
5e3521 688             }
JM 689             config.save();
690
691             milestonesCache.remove(repository.name);
692
693             TicketNotifier notifier = createNotifier();
270e9e 694             for (QueryResult qr : tm.tickets) {
5e3521 695                 Change change = new Change(createdBy);
JM 696                 change.setField(Field.milestone, newName);
697                 TicketModel ticket = updateTicket(repository, qr.number, change);
667163 698                 if (notifyOpenTickets && ticket.isOpen()) {
ce048e 699                     notifier.queueMailing(ticket);
JM 700                 }
5e3521 701             }
667163 702             if (notifyOpenTickets) {
ce048e 703                 notifier.sendAll();
JM 704             }
5e3521 705
JM 706             return true;
707         } catch (IOException e) {
708             log.error("failed to rename milestone " + oldName + " in " + repository, e);
709         } finally {
9ee8b1 710             if (db != null) {
JM 711                 db.close();
712             }
5e3521 713         }
JM 714         return false;
715     }
3a6499 716
5e3521 717     /**
JM 718      * Deletes a milestone.
719      *
720      * @param repository
721      * @param milestone
722      * @param createdBy
723      * @return true if successful
3a6499 724      * @since 1.4.0
5e3521 725      */
JM 726     public synchronized boolean deleteMilestone(RepositoryModel repository, String milestone, String createdBy) {
667163 727         return deleteMilestone(repository, milestone, createdBy, true);
JM 728     }
729
730     /**
731      * Deletes a milestone.
732      *
733      * @param repository
734      * @param milestone
735      * @param createdBy
736      * @param notifyOpenTickets
737      * @return true if successful
738      * @since 1.6.0
739      */
740     public synchronized boolean deleteMilestone(RepositoryModel repository, String milestone,
741             String createdBy, boolean notifyOpenTickets) {
5e3521 742         if (StringUtils.isEmpty(milestone)) {
JM 743             throw new IllegalArgumentException("milestone can not be empty!");
744         }
745         Repository db = null;
746         try {
019958 747             TicketMilestone tm = getMilestone(repository, milestone);
270e9e 748             if (tm == null) {
JM 749                 return false;
750             }
5e3521 751             db = repositoryManager.getRepository(repository.name);
JM 752             StoredConfig config = db.getConfig();
753             config.unsetSection(MILESTONE, milestone);
754             config.save();
755
756             milestonesCache.remove(repository.name);
757
667163 758             TicketNotifier notifier = createNotifier();
019958 759             for (QueryResult qr : tm.tickets) {
667163 760                 Change change = new Change(createdBy);
JM 761                 change.setField(Field.milestone, "");
762                 TicketModel ticket = updateTicket(repository, qr.number, change);
763                 if (notifyOpenTickets && ticket.isOpen()) {
764                     notifier.queueMailing(ticket);
019958 765                 }
JM 766             }
667163 767             if (notifyOpenTickets) {
JM 768                 notifier.sendAll();
769             }
5e3521 770             return true;
JM 771         } catch (IOException e) {
772             log.error("failed to delete milestone " + milestone + " in " + repository, e);
773         } finally {
9ee8b1 774             if (db != null) {
JM 775                 db.close();
776             }
5e3521 777         }
JM 778         return false;
779     }
780
781     /**
4d81c9 782      * Returns the set of assigned ticket ids in the repository.
JM 783      *
784      * @param repository
785      * @return a set of assigned ticket ids in the repository
786      * @since 1.6.0
787      */
788     public abstract Set<Long> getIds(RepositoryModel repository);
789
790     /**
5e3521 791      * Assigns a new ticket id.
JM 792      *
793      * @param repository
794      * @return a new ticket id
3a6499 795      * @since 1.4.0
5e3521 796      */
JM 797     public abstract long assignNewId(RepositoryModel repository);
798
799     /**
800      * Ensures that we have a ticket for this ticket id.
801      *
802      * @param repository
803      * @param ticketId
804      * @return true if the ticket exists
3a6499 805      * @since 1.4.0
5e3521 806      */
JM 807     public abstract boolean hasTicket(RepositoryModel repository, long ticketId);
808
809     /**
810      * Returns all tickets.  This is not a Lucene search!
811      *
812      * @param repository
813      * @return all tickets
3a6499 814      * @since 1.4.0
5e3521 815      */
JM 816     public List<TicketModel> getTickets(RepositoryModel repository) {
817         return getTickets(repository, null);
818     }
819
820     /**
821      * Returns all tickets that satisfy the filter. Retrieving tickets from the
822      * service requires deserializing all journals and building ticket models.
823      * This is an  expensive process and not recommended. Instead, the queryFor
824      * method should be used which executes against the Lucene index.
825      *
826      * @param repository
827      * @param filter
828      *            optional issue filter to only return matching results
829      * @return a list of tickets
3a6499 830      * @since 1.4.0
5e3521 831      */
JM 832     public abstract List<TicketModel> getTickets(RepositoryModel repository, TicketFilter filter);
833
834     /**
835      * Retrieves the ticket.
836      *
837      * @param repository
838      * @param ticketId
839      * @return a ticket, if it exists, otherwise null
3a6499 840      * @since 1.4.0
5e3521 841      */
JM 842     public final TicketModel getTicket(RepositoryModel repository, long ticketId) {
843         TicketKey key = new TicketKey(repository, ticketId);
844         TicketModel ticket = ticketsCache.getIfPresent(key);
3a6499 845
486874 846         // if ticket not cached
5e3521 847         if (ticket == null) {
486874 848             //load ticket
5e3521 849             ticket = getTicketImpl(repository, ticketId);
486874 850             // if ticket exists
5e3521 851             if (ticket != null) {
4d81c9 852                 if (ticket.hasPatchsets() && updateDiffstats) {
486874 853                     Repository r = repositoryManager.getRepository(repository.name);
KW 854                     try {
855                         Patchset patchset = ticket.getCurrentPatchset();
856                         DiffStat diffStat = DiffUtils.getDiffStat(r, patchset.base, patchset.tip);
857                         // diffstat could be null if we have ticket data without the
858                         // commit objects.  e.g. ticket replication without repo
859                         // mirroring
860                         if (diffStat != null) {
861                             ticket.insertions = diffStat.getInsertions();
862                             ticket.deletions = diffStat.getDeletions();
863                         }
864                     } finally {
865                         r.close();
866                     }
867                 }
868                 //cache ticket
5e3521 869                 ticketsCache.put(key, ticket);
JM 870             }
871         }
872         return ticket;
873     }
874
875     /**
876      * Retrieves the ticket.
877      *
878      * @param repository
879      * @param ticketId
880      * @return a ticket, if it exists, otherwise null
3a6499 881      * @since 1.4.0
5e3521 882      */
JM 883     protected abstract TicketModel getTicketImpl(RepositoryModel repository, long ticketId);
884
4d81c9 885
JM 886     /**
887      * Returns the journal used to build a ticket.
888      *
889      * @param repository
890      * @param ticketId
891      * @return the journal for the ticket, if it exists, otherwise null
892      * @since 1.6.0
893      */
894     public final List<Change> getJournal(RepositoryModel repository, long ticketId) {
895         if (hasTicket(repository, ticketId)) {
896             List<Change> journal = getJournalImpl(repository, ticketId);
897             return journal;
898         }
899         return null;
900     }
901
902     /**
903      * Retrieves the ticket journal.
904      *
905      * @param repository
906      * @param ticketId
907      * @return a ticket, if it exists, otherwise null
908      * @since 1.6.0
909      */
910     protected abstract List<Change> getJournalImpl(RepositoryModel repository, long ticketId);
911
5e3521 912     /**
JM 913      * Get the ticket url
914      *
915      * @param ticket
916      * @return the ticket url
3a6499 917      * @since 1.4.0
5e3521 918      */
JM 919     public String getTicketUrl(TicketModel ticket) {
920         final String canonicalUrl = settings.getString(Keys.web.canonicalUrl, "https://localhost:8443");
921         final String hrefPattern = "{0}/tickets?r={1}&h={2,number,0}";
922         return MessageFormat.format(hrefPattern, canonicalUrl, ticket.repository, ticket.number);
923     }
924
925     /**
926      * Get the compare url
927      *
928      * @param base
929      * @param tip
930      * @return the compare url
3a6499 931      * @since 1.4.0
5e3521 932      */
JM 933     public String getCompareUrl(TicketModel ticket, String base, String tip) {
934         final String canonicalUrl = settings.getString(Keys.web.canonicalUrl, "https://localhost:8443");
935         final String hrefPattern = "{0}/compare?r={1}&h={2}..{3}";
936         return MessageFormat.format(hrefPattern, canonicalUrl, ticket.repository, base, tip);
937     }
938
939     /**
940      * Returns true if attachments are supported.
941      *
942      * @return true if attachments are supported
3a6499 943      * @since 1.4.0
5e3521 944      */
JM 945     public abstract boolean supportsAttachments();
946
947     /**
948      * Retrieves the specified attachment from a ticket.
949      *
950      * @param repository
951      * @param ticketId
952      * @param filename
953      * @return an attachment, if found, null otherwise
3a6499 954      * @since 1.4.0
5e3521 955      */
JM 956     public abstract Attachment getAttachment(RepositoryModel repository, long ticketId, String filename);
957
958     /**
959      * Creates a ticket.  Your change must include a repository, author & title,
960      * at a minimum. If your change does not have those minimum requirements a
961      * RuntimeException will be thrown.
962      *
963      * @param repository
964      * @param change
965      * @return true if successful
3a6499 966      * @since 1.4.0
5e3521 967      */
JM 968     public TicketModel createTicket(RepositoryModel repository, Change change) {
969         return createTicket(repository, 0L, change);
970     }
971
972     /**
973      * Creates a ticket.  Your change must include a repository, author & title,
974      * at a minimum. If your change does not have those minimum requirements a
975      * RuntimeException will be thrown.
976      *
977      * @param repository
978      * @param ticketId (if <=0 the ticket id will be assigned)
979      * @param change
980      * @return true if successful
3a6499 981      * @since 1.4.0
5e3521 982      */
JM 983     public TicketModel createTicket(RepositoryModel repository, long ticketId, Change change) {
984
985         if (repository == null) {
986             throw new RuntimeException("Must specify a repository!");
987         }
988         if (StringUtils.isEmpty(change.author)) {
989             throw new RuntimeException("Must specify a change author!");
990         }
991         if (!change.hasField(Field.title)) {
992             throw new RuntimeException("Must specify a title!");
993         }
994
995         change.watch(change.author);
996
997         if (ticketId <= 0L) {
998             ticketId = assignNewId(repository);
999         }
1000
1001         change.setField(Field.status, Status.New);
1002
1003         boolean success = commitChangeImpl(repository, ticketId, change);
1004         if (success) {
1005             TicketModel ticket = getTicket(repository, ticketId);
1006             indexer.index(ticket);
ba5670 1007
JM 1008             // call the ticket hooks
1009             if (pluginManager != null) {
1010                 for (TicketHook hook : pluginManager.getExtensions(TicketHook.class)) {
1011                     try {
1012                         hook.onNewTicket(ticket);
1013                     } catch (Exception e) {
1014                         log.error("Failed to execute extension", e);
1015                     }
1016                 }
1017             }
5e3521 1018             return ticket;
JM 1019         }
1020         return null;
1021     }
1022
1023     /**
1024      * Updates a ticket.
1025      *
1026      * @param repository
1027      * @param ticketId
1028      * @param change
1029      * @return the ticket model if successful
3a6499 1030      * @since 1.4.0
5e3521 1031      */
JM 1032     public final TicketModel updateTicket(RepositoryModel repository, long ticketId, Change change) {
1033         if (change == null) {
1034             throw new RuntimeException("change can not be null!");
1035         }
1036
1037         if (StringUtils.isEmpty(change.author)) {
1038             throw new RuntimeException("must specify a change author!");
1039         }
1040
1041         TicketKey key = new TicketKey(repository, ticketId);
1042         ticketsCache.invalidate(key);
1043
1044         boolean success = commitChangeImpl(repository, ticketId, change);
1045         if (success) {
1046             TicketModel ticket = getTicket(repository, ticketId);
1047             ticketsCache.put(key, ticket);
1048             indexer.index(ticket);
ba5670 1049
JM 1050             // call the ticket hooks
1051             if (pluginManager != null) {
1052                 for (TicketHook hook : pluginManager.getExtensions(TicketHook.class)) {
1053                     try {
1054                         hook.onUpdateTicket(ticket, change);
1055                     } catch (Exception e) {
1056                         log.error("Failed to execute extension", e);
1057                     }
1058                 }
1059             }
5e3521 1060             return ticket;
JM 1061         }
1062         return null;
1063     }
1064
1065     /**
1066      * Deletes all tickets in every repository.
1067      *
1068      * @return true if successful
3a6499 1069      * @since 1.4.0
5e3521 1070      */
JM 1071     public boolean deleteAll() {
1072         List<String> repositories = repositoryManager.getRepositoryList();
1073         BitSet bitset = new BitSet(repositories.size());
1074         for (int i = 0; i < repositories.size(); i++) {
1075             String name = repositories.get(i);
1076             RepositoryModel repository = repositoryManager.getRepositoryModel(name);
1077             boolean success = deleteAll(repository);
1078             bitset.set(i, success);
1079         }
1080         boolean success = bitset.cardinality() == repositories.size();
1081         if (success) {
1082             indexer.deleteAll();
1083             resetCaches();
1084         }
1085         return success;
1086     }
1087
1088     /**
1089      * Deletes all tickets in the specified repository.
1090      * @param repository
1091      * @return true if succesful
3a6499 1092      * @since 1.4.0
5e3521 1093      */
JM 1094     public boolean deleteAll(RepositoryModel repository) {
1095         boolean success = deleteAllImpl(repository);
1096         if (success) {
988334 1097             log.info("Deleted all tickets for {}", repository.name);
5e3521 1098             resetCaches(repository);
JM 1099             indexer.deleteAll(repository);
1100         }
1101         return success;
1102     }
1103
3a6499 1104     /**
JM 1105      * Delete all tickets for the specified repository.
1106      * @param repository
1107      * @return true if successful
1108      * @since 1.4.0
1109      */
5e3521 1110     protected abstract boolean deleteAllImpl(RepositoryModel repository);
JM 1111
1112     /**
1113      * Handles repository renames.
1114      *
1115      * @param oldRepositoryName
1116      * @param newRepositoryName
1117      * @return true if successful
3a6499 1118      * @since 1.4.0
5e3521 1119      */
JM 1120     public boolean rename(RepositoryModel oldRepository, RepositoryModel newRepository) {
1121         if (renameImpl(oldRepository, newRepository)) {
1122             resetCaches(oldRepository);
1123             indexer.deleteAll(oldRepository);
1124             reindex(newRepository);
1125             return true;
1126         }
1127         return false;
1128     }
1129
3a6499 1130     /**
JM 1131      * Renames a repository.
1132      *
1133      * @param oldRepository
1134      * @param newRepository
1135      * @return true if successful
1136      * @since 1.4.0
1137      */
5e3521 1138     protected abstract boolean renameImpl(RepositoryModel oldRepository, RepositoryModel newRepository);
JM 1139
1140     /**
1141      * Deletes a ticket.
1142      *
1143      * @param repository
1144      * @param ticketId
1145      * @param deletedBy
1146      * @return true if successful
3a6499 1147      * @since 1.4.0
5e3521 1148      */
JM 1149     public boolean deleteTicket(RepositoryModel repository, long ticketId, String deletedBy) {
1150         TicketModel ticket = getTicket(repository, ticketId);
1151         boolean success = deleteTicketImpl(repository, ticket, deletedBy);
1152         if (success) {
988334 1153             log.info(MessageFormat.format("Deleted {0} ticket #{1,number,0}: {2}",
JM 1154                     repository.name, ticketId, ticket.title));
5e3521 1155             ticketsCache.invalidate(new TicketKey(repository, ticketId));
JM 1156             indexer.delete(ticket);
1157             return true;
1158         }
1159         return false;
1160     }
1161
1162     /**
1163      * Deletes a ticket.
1164      *
1165      * @param repository
1166      * @param ticket
1167      * @param deletedBy
1168      * @return true if successful
3a6499 1169      * @since 1.4.0
5e3521 1170      */
JM 1171     protected abstract boolean deleteTicketImpl(RepositoryModel repository, TicketModel ticket, String deletedBy);
1172
1173
1174     /**
1175      * Updates the text of an ticket comment.
1176      *
1177      * @param ticket
1178      * @param commentId
1179      *            the id of the comment to revise
1180      * @param updatedBy
1181      *            the author of the updated comment
1182      * @param comment
1183      *            the revised comment
1184      * @return the revised ticket if the change was successful
3a6499 1185      * @since 1.4.0
5e3521 1186      */
JM 1187     public final TicketModel updateComment(TicketModel ticket, String commentId,
1188             String updatedBy, String comment) {
1189         Change revision = new Change(updatedBy);
1190         revision.comment(comment);
1191         revision.comment.id = commentId;
1192         RepositoryModel repository = repositoryManager.getRepositoryModel(ticket.repository);
1193         TicketModel revisedTicket = updateTicket(repository, ticket.number, revision);
1194         return revisedTicket;
1195     }
1196
1197     /**
1198      * Deletes a comment from a ticket.
1199      *
1200      * @param ticket
1201      * @param commentId
1202      *            the id of the comment to delete
1203      * @param deletedBy
1204      *             the user deleting the comment
1205      * @return the revised ticket if the deletion was successful
3a6499 1206      * @since 1.4.0
5e3521 1207      */
JM 1208     public final TicketModel deleteComment(TicketModel ticket, String commentId, String deletedBy) {
1209         Change deletion = new Change(deletedBy);
1210         deletion.comment("");
1211         deletion.comment.id = commentId;
1212         deletion.comment.deleted = true;
1213         RepositoryModel repository = repositoryManager.getRepositoryModel(ticket.repository);
1214         TicketModel revisedTicket = updateTicket(repository, ticket.number, deletion);
1215         return revisedTicket;
1216     }
cd7e4f 1217     
PM 1218     /**
1219      * Deletes a patchset from a ticket.
1220      *
1221      * @param ticket
1222      * @param patchset
1223      *            the patchset to delete (should be the highest revision)
1224      * @param userName
1225      *             the user deleting the commit
1226      * @return the revised ticket if the deletion was successful
1227      * @since 1.8.0
1228      */
1229     public final TicketModel deletePatchset(TicketModel ticket, Patchset patchset, String userName) {
1230         Change deletion = new Change(userName);
1231         deletion.patchset = new Patchset();
1232         deletion.patchset.number = patchset.number;
1233         deletion.patchset.rev = patchset.rev;
1234         deletion.patchset.type = PatchsetType.Delete;
1235         
1236         RepositoryModel repository = repositoryManager.getRepositoryModel(ticket.repository);
1237         TicketModel revisedTicket = updateTicket(repository, ticket.number, deletion);
1238         
1239         return revisedTicket;
1240     } 
5e3521 1241
JM 1242     /**
1243      * Commit a ticket change to the repository.
1244      *
1245      * @param repository
1246      * @param ticketId
1247      * @param change
1248      * @return true, if the change was committed
3a6499 1249      * @since 1.4.0
5e3521 1250      */
JM 1251     protected abstract boolean commitChangeImpl(RepositoryModel repository, long ticketId, Change change);
1252
1253
1254     /**
1255      * Searches for the specified text.  This will use the indexer, if available,
1256      * or will fall back to brute-force retrieval of all tickets and string
1257      * matching.
1258      *
1259      * @param repository
1260      * @param text
1261      * @param page
1262      * @param pageSize
1263      * @return a list of matching tickets
3a6499 1264      * @since 1.4.0
5e3521 1265      */
JM 1266     public List<QueryResult> searchFor(RepositoryModel repository, String text, int page, int pageSize) {
1267         return indexer.searchFor(repository, text, page, pageSize);
1268     }
1269
1270     /**
1271      * Queries the index for the matching tickets.
1272      *
1273      * @param query
1274      * @param page
1275      * @param pageSize
1276      * @param sortBy
1277      * @param descending
1278      * @return a list of matching tickets or an empty list
3a6499 1279      * @since 1.4.0
5e3521 1280      */
JM 1281     public List<QueryResult> queryFor(String query, int page, int pageSize, String sortBy, boolean descending) {
1282         return indexer.queryFor(query, page, pageSize, sortBy, descending);
1283     }
1284
1285     /**
1286      * Destroys an existing index and reindexes all tickets.
1287      * This operation may be expensive and time-consuming.
3a6499 1288      * @since 1.4.0
5e3521 1289      */
JM 1290     public void reindex() {
1291         long start = System.nanoTime();
1292         indexer.deleteAll();
1293         for (String name : repositoryManager.getRepositoryList()) {
1294             RepositoryModel repository = repositoryManager.getRepositoryModel(name);
1295             try {
1296             List<TicketModel> tickets = getTickets(repository);
1297             if (!tickets.isEmpty()) {
1298                 log.info("reindexing {} tickets from {} ...", tickets.size(), repository);
1299                 indexer.index(tickets);
1300                 System.gc();
1301             }
1302             } catch (Exception e) {
1303                 log.error("failed to reindex {}", repository.name);
1304                 log.error(null, e);
1305             }
1306         }
1307         long end = System.nanoTime();
1308         long secs = TimeUnit.NANOSECONDS.toMillis(end - start);
1309         log.info("reindexing completed in {} msecs.", secs);
1310     }
1311
1312     /**
1313      * Destroys any existing index and reindexes all tickets.
1314      * This operation may be expensive and time-consuming.
3a6499 1315      * @since 1.4.0
5e3521 1316      */
JM 1317     public void reindex(RepositoryModel repository) {
1318         long start = System.nanoTime();
1319         List<TicketModel> tickets = getTickets(repository);
1320         indexer.index(tickets);
1321         log.info("reindexing {} tickets from {} ...", tickets.size(), repository);
1322         long end = System.nanoTime();
1323         long secs = TimeUnit.NANOSECONDS.toMillis(end - start);
1324         log.info("reindexing completed in {} msecs.", secs);
988334 1325         resetCaches(repository);
5e3521 1326     }
JM 1327
1328     /**
1329      * Synchronously executes the runnable. This is used for special processing
1330      * of ticket updates, namely merging from the web ui.
1331      *
1332      * @param runnable
3a6499 1333      * @since 1.4.0
5e3521 1334      */
JM 1335     public synchronized void exec(Runnable runnable) {
1336         runnable.run();
1337     }
1338 }