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