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