Paul Martin
2016-04-27 c2188a840bc4153ae92112b04b2e06a90d3944aa
commit | author | age
5e3521 1 /*
JM 2  * Copyright 2014 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.models;
17
18 import java.io.ByteArrayInputStream;
19 import java.io.ByteArrayOutputStream;
20 import java.io.IOException;
21 import java.io.ObjectInputStream;
22 import java.io.ObjectOutputStream;
23 import java.io.Serializable;
24 import java.io.UnsupportedEncodingException;
25 import java.security.MessageDigest;
26 import java.security.NoSuchAlgorithmException;
27 import java.text.MessageFormat;
28 import java.util.ArrayList;
29 import java.util.Arrays;
30 import java.util.Collection;
31 import java.util.Collections;
32 import java.util.Date;
33 import java.util.HashMap;
34 import java.util.LinkedHashMap;
35 import java.util.LinkedHashSet;
36 import java.util.List;
37 import java.util.Map;
b799d5 38 import java.util.NoSuchElementException;
5e3521 39 import java.util.Set;
JM 40 import java.util.TreeSet;
41 import java.util.regex.Matcher;
42 import java.util.regex.Pattern;
43
44 import org.eclipse.jgit.util.RelativeDateFormatter;
45
46 /**
47  * The Gitblit Ticket model, its component classes, and enums.
48  *
49  * @author James Moger
50  *
51  */
52 public class TicketModel implements Serializable, Comparable<TicketModel> {
53
54     private static final long serialVersionUID = 1L;
55
56     public String project;
57
58     public String repository;
59
60     public long number;
61
62     public Date created;
63
64     public String createdBy;
65
66     public Date updated;
67
68     public String updatedBy;
69
70     public String title;
71
72     public String body;
73
74     public String topic;
75
76     public Type type;
77
78     public Status status;
79
80     public String responsible;
81
82     public String milestone;
83
84     public String mergeSha;
85
86     public String mergeTo;
87
88     public List<Change> changes;
89
90     public Integer insertions;
91
92     public Integer deletions;
93
f9c78c 94     public Priority priority;
PM 95     
96     public Severity severity;
97     
5e3521 98     /**
JM 99      * Builds an effective ticket from the collection of changes.  A change may
100      * Add or Subtract information from a ticket, but the collection of changes
101      * is only additive.
102      *
103      * @param changes
104      * @return the effective ticket
105      */
106     public static TicketModel buildTicket(Collection<Change> changes) {
107         TicketModel ticket;
108         List<Change> effectiveChanges = new ArrayList<Change>();
109         Map<String, Change> comments = new HashMap<String, Change>();
c2188a 110         Map<String, Change> references = new HashMap<String, Change>();
cd7e4f 111         Map<Integer, Integer> latestRevisions = new HashMap<Integer, Integer>();
PM 112         
113         int latestPatchsetNumber = -1;
114         
115         List<Integer> deletedPatchsets = new ArrayList<Integer>();
116         
117         for (Change change : changes) {
118             if (change.patchset != null) {
119                 if (change.patchset.isDeleted()) {
120                     deletedPatchsets.add(change.patchset.number);
121                 } else {
122                     Integer latestRev = latestRevisions.get(change.patchset.number);
123                     
124                     if (latestRev == null || change.patchset.rev > latestRev) {
125                         latestRevisions.put(change.patchset.number, change.patchset.rev);
126                     }
127                     
128                     if (change.patchset.number > latestPatchsetNumber) {
129                         latestPatchsetNumber = change.patchset.number;
130                     }    
131                 }
132             }
133         }
134         
5e3521 135         for (Change change : changes) {
JM 136             if (change.comment != null) {
137                 if (comments.containsKey(change.comment.id)) {
138                     Change original = comments.get(change.comment.id);
139                     Change clone = copy(original);
140                     clone.comment.text = change.comment.text;
141                     clone.comment.deleted = change.comment.deleted;
142                     int idx = effectiveChanges.indexOf(original);
143                     effectiveChanges.remove(original);
144                     effectiveChanges.add(idx, clone);
145                     comments.put(clone.comment.id, clone);
146                 } else {
147                     effectiveChanges.add(change);
148                     comments.put(change.comment.id, change);
cd7e4f 149                 }
PM 150             } else if (change.patchset != null) {
151                 //All revisions of a deleted patchset are not displayed
152                 if (!deletedPatchsets.contains(change.patchset.number)) {
153                     
154                     Integer latestRev = latestRevisions.get(change.patchset.number);
155                     
156                     if (    (change.patchset.number < latestPatchsetNumber) 
157                          && (change.patchset.rev == latestRev)) {
158                         change.patchset.canDelete = true;
159                     }
160                     
161                     effectiveChanges.add(change);
5e3521 162                 }
c2188a 163             } else if (change.reference != null){
PM 164                 if (references.containsKey(change.reference.toString())) {
165                     Change original = references.get(change.reference.toString());
166                     Change clone = copy(original);
167                     clone.reference.deleted = change.reference.deleted;
168                     int idx = effectiveChanges.indexOf(original);
169                     effectiveChanges.remove(original);
170                     effectiveChanges.add(idx, clone);
171                 } else {
172                     effectiveChanges.add(change);
173                     references.put(change.reference.toString(), change);
174                 }
5e3521 175             } else {
JM 176                 effectiveChanges.add(change);
177             }
178         }
179
180         // effective ticket
181         ticket = new TicketModel();
182         for (Change change : effectiveChanges) {
c2188a 183             //Ensure deleted items are not included
5e3521 184             if (!change.hasComment()) {
JM 185                 change.comment = null;
c2188a 186             }
PM 187             if (!change.hasReference()) {
188                 change.reference = null;
189             }
190             if (!change.hasPatchset()) {
191                 change.patchset = null;
5e3521 192             }
JM 193             ticket.applyChange(change);
194         }
195         return ticket;
196     }
197
198     public TicketModel() {
199         // the first applied change set the date appropriately
200         created = new Date(0);
201         changes = new ArrayList<Change>();
202         status = Status.New;
203         type = Type.defaultType;
f9c78c 204         priority = Priority.defaultPriority;
PM 205         severity = Severity.defaultSeverity;
5e3521 206     }
JM 207
208     public boolean isOpen() {
209         return !status.isClosed();
210     }
211
212     public boolean isClosed() {
213         return status.isClosed();
214     }
215
216     public boolean isMerged() {
217         return isClosed() && !isEmpty(mergeSha);
218     }
219
220     public boolean isProposal() {
221         return Type.Proposal == type;
222     }
223
224     public boolean isBug() {
225         return Type.Bug == type;
226     }
227
228     public Date getLastUpdated() {
229         return updated == null ? created : updated;
230     }
231
232     public boolean hasPatchsets() {
233         return getPatchsets().size() > 0;
234     }
235
236     /**
237      * Returns true if multiple participants are involved in discussing a ticket.
238      * The ticket creator is excluded from this determination because a
239      * discussion requires more than one participant.
240      *
241      * @return true if this ticket has a discussion
242      */
243     public boolean hasDiscussion() {
244         for (Change change : getComments()) {
245             if (!change.author.equals(createdBy)) {
246                 return true;
247             }
248         }
249         return false;
250     }
251
252     /**
253      * Returns the list of changes with comments.
254      *
255      * @return
256      */
257     public List<Change> getComments() {
258         List<Change> list = new ArrayList<Change>();
259         for (Change change : changes) {
260             if (change.hasComment()) {
261                 list.add(change);
262             }
263         }
264         return list;
265     }
266
267     /**
268      * Returns the list of participants for the ticket.
269      *
270      * @return the list of participants
271      */
272     public List<String> getParticipants() {
273         Set<String> set = new LinkedHashSet<String>();
274         for (Change change : changes) {
275             if (change.isParticipantChange()) {
276                 set.add(change.author);
277             }
278         }
279         if (responsible != null && responsible.length() > 0) {
280             set.add(responsible);
281         }
282         return new ArrayList<String>(set);
283     }
284
285     public boolean hasLabel(String label) {
286         return getLabels().contains(label);
287     }
288
289     public List<String> getLabels() {
290         return getList(Field.labels);
291     }
292
293     public boolean isResponsible(String username) {
294         return username.equals(responsible);
295     }
296
297     public boolean isAuthor(String username) {
298         return username.equals(createdBy);
299     }
300
301     public boolean isReviewer(String username) {
302         return getReviewers().contains(username);
303     }
304
305     public List<String> getReviewers() {
306         return getList(Field.reviewers);
307     }
308
309     public boolean isWatching(String username) {
310         return getWatchers().contains(username);
311     }
312
313     public List<String> getWatchers() {
314         return getList(Field.watchers);
315     }
316
317     public boolean isVoter(String username) {
318         return getVoters().contains(username);
319     }
320
321     public List<String> getVoters() {
322         return getList(Field.voters);
323     }
324
325     public List<String> getMentions() {
326         return getList(Field.mentions);
327     }
328
329     protected List<String> getList(Field field) {
330         Set<String> set = new TreeSet<String>();
331         for (Change change : changes) {
332             if (change.hasField(field)) {
333                 String values = change.getString(field);
334                 for (String value : values.split(",")) {
335                     switch (value.charAt(0)) {
336                     case '+':
337                         set.add(value.substring(1));
338                         break;
339                     case '-':
340                         set.remove(value.substring(1));
341                         break;
342                     default:
343                         set.add(value);
344                     }
345                 }
346             }
347         }
348         if (!set.isEmpty()) {
349             return new ArrayList<String>(set);
350         }
351         return Collections.emptyList();
352     }
353
354     public Attachment getAttachment(String name) {
355         Attachment attachment = null;
356         for (Change change : changes) {
357             if (change.hasAttachments()) {
358                 Attachment a = change.getAttachment(name);
359                 if (a != null) {
360                     attachment = a;
361                 }
362             }
363         }
364         return attachment;
365     }
366
367     public boolean hasAttachments() {
368         for (Change change : changes) {
369             if (change.hasAttachments()) {
370                 return true;
371             }
372         }
373         return false;
374     }
375
c2188a 376     public boolean hasReferences() {
PM 377         for (Change change : changes) {
378             if (change.hasReference()) {
379                 return true;
380             }
381         }
382         return false;
383     }
384     
5e3521 385     public List<Attachment> getAttachments() {
JM 386         List<Attachment> list = new ArrayList<Attachment>();
387         for (Change change : changes) {
388             if (change.hasAttachments()) {
389                 list.addAll(change.attachments);
390             }
391         }
392         return list;
393     }
394
c2188a 395     public List<Reference> getReferences() {
PM 396         List<Reference> list = new ArrayList<Reference>();
397         for (Change change : changes) {
398             if (change.hasReference()) {
399                 list.add(change.reference);
400             }
401         }
402         return list;
403     }
404     
5e3521 405     public List<Patchset> getPatchsets() {
JM 406         List<Patchset> list = new ArrayList<Patchset>();
407         for (Change change : changes) {
408             if (change.patchset != null) {
409                 list.add(change.patchset);
410             }
411         }
412         return list;
413     }
414
415     public List<Patchset> getPatchsetRevisions(int number) {
416         List<Patchset> list = new ArrayList<Patchset>();
417         for (Change change : changes) {
418             if (change.patchset != null) {
419                 if (number == change.patchset.number) {
420                     list.add(change.patchset);
421                 }
422             }
423         }
424         return list;
425     }
426
427     public Patchset getPatchset(String sha) {
428         for (Change change : changes) {
429             if (change.patchset != null) {
430                 if (sha.equals(change.patchset.tip)) {
431                     return change.patchset;
432                 }
433             }
434         }
435         return null;
436     }
437
438     public Patchset getPatchset(int number, int rev) {
439         for (Change change : changes) {
440             if (change.patchset != null) {
441                 if (number == change.patchset.number && rev == change.patchset.rev) {
442                     return change.patchset;
443                 }
444             }
445         }
446         return null;
447     }
448
449     public Patchset getCurrentPatchset() {
450         Patchset patchset = null;
451         for (Change change : changes) {
452             if (change.patchset != null) {
453                 if (patchset == null) {
454                     patchset = change.patchset;
455                 } else if (patchset.compareTo(change.patchset) == 1) {
456                     patchset = change.patchset;
457                 }
458             }
459         }
460         return patchset;
461     }
462
463     public boolean isCurrent(Patchset patchset) {
464         if (patchset == null) {
465             return false;
466         }
467         Patchset curr = getCurrentPatchset();
468         if (curr == null) {
469             return false;
470         }
471         return curr.equals(patchset);
472     }
473
474     public List<Change> getReviews(Patchset patchset) {
475         if (patchset == null) {
476             return Collections.emptyList();
477         }
478         // collect the patchset reviews by author
479         // the last review by the author is the
480         // official review
481         Map<String, Change> reviews = new LinkedHashMap<String, TicketModel.Change>();
482         for (Change change : changes) {
483             if (change.hasReview()) {
484                 if (change.review.isReviewOf(patchset)) {
485                     reviews.put(change.author, change);
486                 }
487             }
488         }
489         return new ArrayList<Change>(reviews.values());
490     }
491
492
493     public boolean isApproved(Patchset patchset) {
494         if (patchset == null) {
495             return false;
496         }
497         boolean approved = false;
498         boolean vetoed = false;
499         for (Change change : getReviews(patchset)) {
500             if (change.hasReview()) {
501                 if (change.review.isReviewOf(patchset)) {
502                     if (Score.approved == change.review.score) {
503                         approved = true;
504                     } else if (Score.vetoed == change.review.score) {
505                         vetoed = true;
506                     }
507                 }
508             }
509         }
510         return approved && !vetoed;
511     }
512
513     public boolean isVetoed(Patchset patchset) {
514         if (patchset == null) {
515             return false;
516         }
517         for (Change change : getReviews(patchset)) {
518             if (change.hasReview()) {
519                 if (change.review.isReviewOf(patchset)) {
520                     if (Score.vetoed == change.review.score) {
521                         return true;
522                     }
523                 }
524             }
525         }
526         return false;
527     }
528
529     public Review getReviewBy(String username) {
530         for (Change change : getReviews(getCurrentPatchset())) {
531             if (change.author.equals(username)) {
532                 return change.review;
533             }
534         }
535         return null;
536     }
537
538     public boolean isPatchsetAuthor(String username) {
539         for (Change change : changes) {
540             if (change.hasPatchset()) {
541                 if (change.author.equals(username)) {
542                     return true;
543                 }
544             }
545         }
546         return false;
547     }
548
549     public void applyChange(Change change) {
550         if (changes.size() == 0) {
551             // first change created the ticket
552             created = change.date;
553             createdBy = change.author;
554             status = Status.New;
555         } else if (created == null || change.date.after(created)) {
556             // track last ticket update
557             updated = change.date;
558             updatedBy = change.author;
559         }
560
561         if (change.isMerge()) {
562             // identify merge patchsets
563             if (isEmpty(responsible)) {
564                 responsible = change.author;
565             }
566             status = Status.Merged;
567         }
568
569         if (change.hasFieldChanges()) {
570             for (Map.Entry<Field, String> entry : change.fields.entrySet()) {
571                 Field field = entry.getKey();
572                 Object value = entry.getValue();
573                 switch (field) {
574                 case type:
575                     type = TicketModel.Type.fromObject(value, type);
576                     break;
577                 case status:
578                     status = TicketModel.Status.fromObject(value, status);
579                     break;
580                 case title:
581                     title = toString(value);
582                     break;
583                 case body:
584                     body = toString(value);
585                     break;
586                 case topic:
587                     topic = toString(value);
588                     break;
589                 case responsible:
590                     responsible = toString(value);
591                     break;
592                 case milestone:
593                     milestone = toString(value);
594                     break;
595                 case mergeTo:
596                     mergeTo = toString(value);
597                     break;
598                 case mergeSha:
599                     mergeSha = toString(value);
f9c78c 600                     break;
PM 601                 case priority:
602                     priority = TicketModel.Priority.fromObject(value, priority);
603                     break;
604                 case severity:
605                     severity = TicketModel.Severity.fromObject(value, severity);
5e3521 606                     break;
JM 607                 default:
608                     // unknown
609                     break;
610                 }
611             }
612         }
613
c2188a 614         // add real changes to the ticket and ensure deleted changes are removed
PM 615         if (change.isEmptyChange()) {
616             changes.remove(change);
617         } else {
618             changes.add(change);
619         }
5e3521 620     }
JM 621
622     protected String toString(Object value) {
623         if (value == null) {
624             return null;
625         }
626         return value.toString();
627     }
628
629     public String toIndexableString() {
630         StringBuilder sb = new StringBuilder();
631         if (!isEmpty(title)) {
632             sb.append(title).append('\n');
633         }
634         if (!isEmpty(body)) {
635             sb.append(body).append('\n');
636         }
637         for (Change change : changes) {
638             if (change.hasComment()) {
639                 sb.append(change.comment.text);
640                 sb.append('\n');
641             }
642         }
643         return sb.toString();
644     }
645
646     @Override
647     public String toString() {
648         StringBuilder sb = new StringBuilder();
649         sb.append("#");
650         sb.append(number);
651         sb.append(": " + title + "\n");
652         for (Change change : changes) {
653             sb.append(change);
654             sb.append('\n');
655         }
656         return sb.toString();
657     }
658
659     @Override
660     public int compareTo(TicketModel o) {
661         return o.created.compareTo(created);
662     }
663
664     @Override
665     public boolean equals(Object o) {
666         if (o instanceof TicketModel) {
667             return number == ((TicketModel) o).number;
668         }
669         return super.equals(o);
670     }
671
672     @Override
673     public int hashCode() {
674         return (repository + number).hashCode();
675     }
676
677     /**
678      * Encapsulates a ticket change
679      */
680     public static class Change implements Serializable, Comparable<Change> {
681
682         private static final long serialVersionUID = 1L;
683
684         public final Date date;
685
686         public final String author;
687
688         public Comment comment;
689
c2188a 690         public Reference reference;
PM 691
5e3521 692         public Map<Field, String> fields;
JM 693
694         public Set<Attachment> attachments;
695
696         public Patchset patchset;
697
698         public Review review;
699
700         private transient String id;
c2188a 701
PM 702         //Once links have been made they become a reference on the target ticket
703         //The ticket service handles promoting links to references
704         public transient List<TicketLink> pendingLinks;
5e3521 705
JM 706         public Change(String author) {
707             this(author, new Date());
708         }
709
710         public Change(String author, Date date) {
711             this.date = date;
712             this.author = author;
713         }
714
715         public boolean isStatusChange() {
716             return hasField(Field.status);
717         }
718
719         public Status getStatus() {
720             Status state = Status.fromObject(getField(Field.status), null);
721             return state;
722         }
723
724         public boolean isMerge() {
725             return hasField(Field.status) && hasField(Field.mergeSha);
726         }
727
728         public boolean hasPatchset() {
c2188a 729             return patchset != null && !patchset.isDeleted();
5e3521 730         }
JM 731
732         public boolean hasReview() {
733             return review != null;
734         }
735
736         public boolean hasComment() {
fafeb6 737             return comment != null && !comment.isDeleted() && comment.text != null;
5e3521 738         }
c2188a 739         
PM 740         public boolean hasReference() {
741             return reference != null && !reference.isDeleted();
742         }
743
744         public boolean hasPendingLinks() {
745             return pendingLinks != null && pendingLinks.size() > 0;
746         }
5e3521 747
JM 748         public Comment comment(String text) {
749             comment = new Comment(text);
750             comment.id = TicketModel.getSHA1(date.toString() + author + text);
751
c2188a 752             // parse comment looking for ref #n
PM 753             //TODO: Ideally set via settings
754             String x = "(?:ref|task|issue|bug)?[\\s-]*#(\\d+)";
755
756             try {
757                 Pattern p = Pattern.compile(x, Pattern.CASE_INSENSITIVE);
758                 Matcher m = p.matcher(text);
759                 while (m.find()) {
760                     String val = m.group(1);
761                     long targetTicketId = Long.parseLong(val);
762                     
763                     if (targetTicketId > 0) {
764                         if (pendingLinks == null) {
765                             pendingLinks = new ArrayList<TicketLink>();
766                         }
767                         
768                         pendingLinks.add(new TicketLink(targetTicketId, TicketAction.Comment));
769                     }
770                 }
771             } catch (Exception e) {
772                 // ignore
773             }
774             
5e3521 775             try {
JM 776                 Pattern mentions = Pattern.compile("\\s@([A-Za-z0-9-_]+)");
777                 Matcher m = mentions.matcher(text);
778                 while (m.find()) {
779                     String username = m.group(1);
780                     plusList(Field.mentions, username);
781                 }
782             } catch (Exception e) {
783                 // ignore
784             }
785             return comment;
786         }
787
c2188a 788         public Reference referenceCommit(String commitHash) {
PM 789             reference = new Reference(commitHash);
790             return reference;
791         }
792
793         public Reference referenceTicket(long ticketId, String changeHash) {
794             reference = new Reference(ticketId, changeHash);
795             return reference;
796         }
797         
5e3521 798         public Review review(Patchset patchset, Score score, boolean addReviewer) {
JM 799             if (addReviewer) {
800                 plusList(Field.reviewers, author);
801             }
802             review = new Review(patchset.number, patchset.rev);
803             review.score = score;
804             return review;
805         }
806
807         public boolean hasAttachments() {
808             return !TicketModel.isEmpty(attachments);
809         }
810
811         public void addAttachment(Attachment attachment) {
812             if (attachments == null) {
813                 attachments = new LinkedHashSet<Attachment>();
814             }
815             attachments.add(attachment);
816         }
817
818         public Attachment getAttachment(String name) {
819             if (attachments != null) {
820                 for (Attachment attachment : attachments) {
821                     if (attachment.name.equalsIgnoreCase(name)) {
822                         return attachment;
823                     }
824                 }
825             }
826             return null;
827         }
828
829         public boolean isParticipantChange() {
830             if (hasComment()
831                     || hasReview()
832                     || hasPatchset()
833                     || hasAttachments()) {
834                 return true;
835             }
836
837             if (TicketModel.isEmpty(fields)) {
838                 return false;
839             }
840
841             // identify real ticket field changes
842             Map<Field, String> map = new HashMap<Field, String>(fields);
843             map.remove(Field.watchers);
844             map.remove(Field.voters);
845             return !map.isEmpty();
846         }
847
848         public boolean hasField(Field field) {
849             return !TicketModel.isEmpty(getString(field));
850         }
851
852         public boolean hasFieldChanges() {
853             return !TicketModel.isEmpty(fields);
854         }
855
856         public String getField(Field field) {
857             if (fields != null) {
858                 return fields.get(field);
859             }
860             return null;
861         }
862
863         public void setField(Field field, Object value) {
864             if (fields == null) {
865                 fields = new LinkedHashMap<Field, String>();
866             }
867             if (value == null) {
868                 fields.put(field, null);
869             } else if (Enum.class.isAssignableFrom(value.getClass())) {
870                 fields.put(field, ((Enum<?>) value).name());
871             } else {
872                 fields.put(field, value.toString());
873             }
874         }
875
876         public void remove(Field field) {
877             if (fields != null) {
878                 fields.remove(field);
879             }
880         }
881
882         public String getString(Field field) {
883             String value = getField(field);
884             if (value == null) {
885                 return null;
886             }
887             return value;
888         }
889
890         public void watch(String... username) {
891             plusList(Field.watchers, username);
892         }
893
894         public void unwatch(String... username) {
895             minusList(Field.watchers, username);
896         }
897
898         public void vote(String... username) {
899             plusList(Field.voters, username);
900         }
901
902         public void unvote(String... username) {
903             minusList(Field.voters, username);
904         }
905
906         public void label(String... label) {
907             plusList(Field.labels, label);
908         }
909
910         public void unlabel(String... label) {
911             minusList(Field.labels, label);
912         }
913
914         protected void plusList(Field field, String... items) {
915             modList(field, "+", items);
916         }
917
918         protected void minusList(Field field, String... items) {
919             modList(field, "-", items);
920         }
921
922         private void modList(Field field, String prefix, String... items) {
923             List<String> list = new ArrayList<String>();
924             for (String item : items) {
925                 list.add(prefix + item);
926             }
120794 927             if (hasField(field)) {
JM 928                 String flat = getString(field);
929                 if (isEmpty(flat)) {
930                     // field is empty, use this list
931                     setField(field, join(list, ","));
932                 } else {
933                     // merge this list into the existing field list
934                     Set<String> set = new TreeSet<String>(Arrays.asList(flat.split(",")));
935                     set.addAll(list);
936                     setField(field, join(set, ","));
937                 }
938             } else {
939                 // does not have a list for this field
940                 setField(field, join(list, ","));
941             }
5e3521 942         }
JM 943
944         public String getId() {
945             if (id == null) {
946                 id = getSHA1(Long.toHexString(date.getTime()) + author);
947             }
948             return id;
949         }
950
951         @Override
952         public int compareTo(Change c) {
953             return date.compareTo(c.date);
954         }
955
956         @Override
957         public int hashCode() {
958             return getId().hashCode();
959         }
960
961         @Override
962         public boolean equals(Object o) {
963             if (o instanceof Change) {
964                 return getId().equals(((Change) o).getId());
965             }
966             return false;
967         }
c2188a 968         
PM 969         /*
970          * Identify if this is an empty change. i.e. only an author and date is defined.
971          * This can occur when items have been deleted
972          * @returns true if the change is empty
973          */
974         private boolean isEmptyChange() {
975             return ((comment == null) && (reference == null) && 
976                     (fields == null) && (attachments == null) && 
977                     (patchset == null) && (review == null));
978         }
5e3521 979
JM 980         @Override
981         public String toString() {
982             StringBuilder sb = new StringBuilder();
983             sb.append(RelativeDateFormatter.format(date));
984             if (hasComment()) {
985                 sb.append(" commented on by ");
986             } else if (hasPatchset()) {
987                 sb.append(MessageFormat.format(" {0} uploaded by ", patchset));
c2188a 988             } else if (hasReference()) {
PM 989                 sb.append(MessageFormat.format(" referenced in {0} by ", reference));
5e3521 990             } else {
JM 991                 sb.append(" changed by ");
992             }
993             sb.append(author).append(" - ");
994             if (hasComment()) {
995                 if (comment.isDeleted()) {
996                     sb.append("(deleted) ");
997                 }
998                 sb.append(comment.text).append(" ");
999             }
1000
1001             if (hasFieldChanges()) {
1002                 for (Map.Entry<Field, String> entry : fields.entrySet()) {
1003                     sb.append("\n  ");
1004                     sb.append(entry.getKey().name());
1005                     sb.append(':');
1006                     sb.append(entry.getValue());
1007                 }
1008             }
1009             return sb.toString();
1010         }
1011     }
1012
1013     /**
1014      * Returns true if the string is null or empty.
1015      *
1016      * @param value
1017      * @return true if string is null or empty
1018      */
1019     static boolean isEmpty(String value) {
1020         return value == null || value.trim().length() == 0;
1021     }
1022
1023     /**
1024      * Returns true if the collection is null or empty
1025      *
1026      * @param collection
1027      * @return
1028      */
1029     static boolean isEmpty(Collection<?> collection) {
1030         return collection == null || collection.size() == 0;
1031     }
1032
1033     /**
1034      * Returns true if the map is null or empty
1035      *
1036      * @param map
1037      * @return
1038      */
1039     static boolean isEmpty(Map<?, ?> map) {
1040         return map == null || map.size() == 0;
1041     }
1042
1043     /**
1044      * Calculates the SHA1 of the string.
1045      *
1046      * @param text
1047      * @return sha1 of the string
1048      */
1049     static String getSHA1(String text) {
1050         try {
1051             byte[] bytes = text.getBytes("iso-8859-1");
1052             return getSHA1(bytes);
1053         } catch (UnsupportedEncodingException u) {
1054             throw new RuntimeException(u);
1055         }
1056     }
1057
1058     /**
1059      * Calculates the SHA1 of the byte array.
1060      *
1061      * @param bytes
1062      * @return sha1 of the byte array
1063      */
1064     static String getSHA1(byte[] bytes) {
1065         try {
1066             MessageDigest md = MessageDigest.getInstance("SHA-1");
1067             md.update(bytes, 0, bytes.length);
1068             byte[] digest = md.digest();
1069             return toHex(digest);
1070         } catch (NoSuchAlgorithmException t) {
1071             throw new RuntimeException(t);
1072         }
1073     }
1074
1075     /**
1076      * Returns the hex representation of the byte array.
1077      *
1078      * @param bytes
1079      * @return byte array as hex string
1080      */
1081     static String toHex(byte[] bytes) {
1082         StringBuilder sb = new StringBuilder(bytes.length * 2);
1083         for (int i = 0; i < bytes.length; i++) {
1084             if ((bytes[i] & 0xff) < 0x10) {
1085                 sb.append('0');
1086             }
1087             sb.append(Long.toString(bytes[i] & 0xff, 16));
1088         }
1089         return sb.toString();
1090     }
1091
1092     /**
1093      * Join the list of strings into a single string with a space separator.
1094      *
1095      * @param values
1096      * @return joined list
1097      */
1098     static String join(Collection<String> values) {
1099         return join(values, " ");
1100     }
1101
1102     /**
1103      * Join the list of strings into a single string with the specified
1104      * separator.
1105      *
1106      * @param values
1107      * @param separator
1108      * @return joined list
1109      */
1110     static String join(String[]  values, String separator) {
1111         return join(Arrays.asList(values), separator);
1112     }
1113
1114     /**
1115      * Join the list of strings into a single string with the specified
1116      * separator.
1117      *
1118      * @param values
1119      * @param separator
1120      * @return joined list
1121      */
1122     static String join(Collection<String> values, String separator) {
1123         StringBuilder sb = new StringBuilder();
1124         for (String value : values) {
1125             sb.append(value).append(separator);
1126         }
1127         if (sb.length() > 0) {
1128             // truncate trailing separator
1129             sb.setLength(sb.length() - separator.length());
1130         }
1131         return sb.toString().trim();
1132     }
1133
1134
1135     /**
1136      * Produce a deep copy of the given object. Serializes the entire object to
1137      * a byte array in memory. Recommended for relatively small objects.
1138      */
1139     @SuppressWarnings("unchecked")
1140     static <T> T copy(T original) {
1141         T o = null;
1142         try {
1143             ByteArrayOutputStream byteOut = new ByteArrayOutputStream();
1144             ObjectOutputStream oos = new ObjectOutputStream(byteOut);
1145             oos.writeObject(original);
1146             ByteArrayInputStream byteIn = new ByteArrayInputStream(byteOut.toByteArray());
1147             ObjectInputStream ois = new ObjectInputStream(byteIn);
1148             try {
1149                 o = (T) ois.readObject();
1150             } catch (ClassNotFoundException cex) {
1151                 // actually can not happen in this instance
1152             }
1153         } catch (IOException iox) {
1154             // doesn't seem likely to happen as these streams are in memory
1155             throw new RuntimeException(iox);
1156         }
1157         return o;
1158     }
1159
1160     public static class Patchset implements Serializable, Comparable<Patchset> {
1161
1162         private static final long serialVersionUID = 1L;
1163
1164         public int number;
1165         public int rev;
1166         public String tip;
1167         public String parent;
1168         public String base;
1169         public int insertions;
1170         public int deletions;
1171         public int commits;
1172         public int added;
1173         public PatchsetType type;
1174
cd7e4f 1175         public transient boolean canDelete = false;
PM 1176
5e3521 1177         public boolean isFF() {
JM 1178             return PatchsetType.FastForward == type;
cd7e4f 1179         }
PM 1180
1181         public boolean isDeleted() {
1182             return PatchsetType.Delete == type;
5e3521 1183         }
JM 1184
1185         @Override
1186         public int hashCode() {
1187             return toString().hashCode();
1188         }
1189
1190         @Override
1191         public boolean equals(Object o) {
1192             if (o instanceof Patchset) {
1193                 return hashCode() == o.hashCode();
1194             }
1195             return false;
1196         }
1197
1198         @Override
1199         public int compareTo(Patchset p) {
1200             if (number > p.number) {
1201                 return -1;
1202             } else if (p.number > number) {
1203                 return 1;
1204             } else {
1205                 // same patchset, different revision
1206                 if (rev > p.rev) {
1207                     return -1;
1208                 } else if (p.rev > rev) {
1209                     return 1;
1210                 } else {
1211                     // same patchset & revision
1212                     return 0;
1213                 }
1214             }
1215         }
1216
1217         @Override
1218         public String toString() {
1219             return "patchset " + number + " revision " + rev;
1220         }
1221     }
1222
1223     public static class Comment implements Serializable {
1224
1225         private static final long serialVersionUID = 1L;
1226
1227         public String text;
1228
1229         public String id;
1230
1231         public Boolean deleted;
1232
1233         public CommentSource src;
1234
1235         public String replyTo;
1236
1237         Comment(String text) {
1238             this.text = text;
1239         }
1240
1241         public boolean isDeleted() {
1242             return deleted != null && deleted;
1243         }
1244
1245         @Override
1246         public String toString() {
1247             return text;
1248         }
1249     }
c2188a 1250     
PM 1251     
1252     public static enum TicketAction {
1253         Commit, Comment, Patchset, Close
1254     }
1255     
1256     //Intentionally not serialized, links are persisted as "references"
1257     public static class TicketLink {
1258         public long targetTicketId;
1259         public String hash;
1260         public TicketAction action;
1261         public boolean success;
1262         public boolean isDelete;
1263         
1264         public TicketLink(long targetTicketId, TicketAction action) {
1265             this.targetTicketId = targetTicketId;
1266             this.action = action;
1267             success = false;
1268             isDelete = false;
1269         }
1270         
1271         public TicketLink(long targetTicketId, TicketAction action, String hash) {
1272             this.targetTicketId = targetTicketId;
1273             this.action = action;
1274             this.hash = hash;
1275             success = false;
1276             isDelete = false;
1277         }
1278     }
1279     
1280     public static enum ReferenceType {
1281         Undefined, Commit, Ticket;
1282     
1283         @Override
1284         public String toString() {
1285             return name().toLowerCase().replace('_', ' ');
1286         }
1287         
1288         public static ReferenceType fromObject(Object o, ReferenceType defaultType) {
1289             if (o instanceof ReferenceType) {
1290                 // cast and return
1291                 return (ReferenceType) o;
1292             } else if (o instanceof String) {
1293                 // find by name
1294                 for (ReferenceType type : values()) {
1295                     String str = o.toString();
1296                     if (type.name().equalsIgnoreCase(str)
1297                             || type.toString().equalsIgnoreCase(str)) {
1298                         return type;
1299                     }
1300                 }
1301             } else if (o instanceof Number) {
1302                 // by ordinal
1303                 int id = ((Number) o).intValue();
1304                 if (id >= 0 && id < values().length) {
1305                     return values()[id];
1306                 }
1307             }
1308
1309             return defaultType;
1310         }
1311     }
1312     
1313     public static class Reference implements Serializable {
1314     
1315         private static final long serialVersionUID = 1L;
1316         
1317         public String hash;
1318         public Long ticketId;
1319         
1320         public Boolean deleted;
1321         
1322         Reference(String commitHash) {
1323             this.hash = commitHash;
1324         }
1325         
1326         Reference(long ticketId, String changeHash) {
1327             this.ticketId = ticketId;
1328             this.hash = changeHash;
1329         }
1330         
1331         public ReferenceType getSourceType(){
1332             if (hash != null) {
1333                 if (ticketId != null) {
1334                     return ReferenceType.Ticket;
1335                 } else {
1336                     return ReferenceType.Commit;
1337                 }
1338             }
1339             
1340             return ReferenceType.Undefined;
1341         }
1342         
1343         public boolean isDeleted() {
1344             return deleted != null && deleted;
1345         }
1346         
1347         @Override
1348         public String toString() {
1349             switch (getSourceType()) {
1350                 case Commit: return hash;
1351                 case Ticket: return ticketId.toString() + "#" + hash;
1352                 default: {} break;
1353             }
1354             
1355             return String.format("Unknown Reference Type");
1356         }
1357     }
5e3521 1358
JM 1359     public static class Attachment implements Serializable {
1360
1361         private static final long serialVersionUID = 1L;
1362
1363         public final String name;
1364         public long size;
1365         public byte[] content;
1366         public Boolean deleted;
1367
1368         public Attachment(String name) {
1369             this.name = name;
1370         }
1371
1372         public boolean isDeleted() {
1373             return deleted != null && deleted;
1374         }
1375
1376         @Override
1377         public int hashCode() {
1378             return name.hashCode();
1379         }
1380
1381         @Override
1382         public boolean equals(Object o) {
1383             if (o instanceof Attachment) {
1384                 return name.equalsIgnoreCase(((Attachment) o).name);
1385             }
1386             return false;
1387         }
1388
1389         @Override
1390         public String toString() {
1391             return name;
1392         }
1393     }
1394
1395     public static class Review implements Serializable {
1396
1397         private static final long serialVersionUID = 1L;
1398
1399         public final int patchset;
1400
1401         public final int rev;
1402
1403         public Score score;
1404
1405         public Review(int patchset, int revision) {
1406             this.patchset = patchset;
1407             this.rev = revision;
1408         }
1409
1410         public boolean isReviewOf(Patchset p) {
1411             return patchset == p.number && rev == p.rev;
1412         }
1413
1414         @Override
1415         public String toString() {
1416             return "review of patchset " + patchset + " rev " + rev + ":" + score;
1417         }
1418     }
1419
1420     public static enum Score {
b799d5 1421         approved(2), looks_good(1), not_reviewed(0), needs_improvement(-1), vetoed(
DO 1422                 -2);
5e3521 1423
JM 1424         final int value;
1425
1426         Score(int value) {
1427             this.value = value;
1428         }
1429
1430         public int getValue() {
1431             return value;
1432         }
1433
1434         @Override
1435         public String toString() {
1436             return name().toLowerCase().replace('_', ' ');
1437         }
b799d5 1438
DO 1439         public static Score fromScore(int score) {
1440             for (Score s : values()) {
1441                 if (s.getValue() == score) {
1442                     return s;
1443                 }
1444             }
1445             throw new NoSuchElementException(String.valueOf(score));
1446         }
5e3521 1447     }
JM 1448
1449     public static enum Field {
1450         title, body, responsible, type, status, milestone, mergeSha, mergeTo,
f9c78c 1451         topic, labels, watchers, reviewers, voters, mentions, priority, severity;
5e3521 1452     }
JM 1453
1454     public static enum Type {
f5d568 1455         Enhancement, Task, Bug, Proposal, Question, Maintenance;
5e3521 1456
JM 1457         public static Type defaultType = Task;
1458
1459         public static Type [] choices() {
f5d568 1460             return new Type [] { Enhancement, Task, Bug, Question, Maintenance };
5e3521 1461         }
JM 1462
1463         @Override
1464         public String toString() {
1465             return name().toLowerCase().replace('_', ' ');
1466         }
1467
1468         public static Type fromObject(Object o, Type defaultType) {
1469             if (o instanceof Type) {
1470                 // cast and return
1471                 return (Type) o;
1472             } else if (o instanceof String) {
1473                 // find by name
1474                 for (Type type : values()) {
1475                     String str = o.toString();
1476                     if (type.name().equalsIgnoreCase(str)
1477                             || type.toString().equalsIgnoreCase(str)) {
1478                         return type;
1479                     }
1480                 }
1481             } else if (o instanceof Number) {
1482                 // by ordinal
1483                 int id = ((Number) o).intValue();
1484                 if (id >= 0 && id < values().length) {
1485                     return values()[id];
1486                 }
1487             }
1488
1489             return defaultType;
1490         }
1491     }
1492
1493     public static enum Status {
4881b8 1494         New, Open, Closed, Resolved, Fixed, Merged, Wontfix, Declined, Duplicate, Invalid, Abandoned, On_Hold, No_Change_Required;
5e3521 1495
4881b8 1496         public static Status [] requestWorkflow = { Open, Resolved, Declined, Duplicate, Invalid, Abandoned, On_Hold, No_Change_Required };
5e3521 1497
4881b8 1498         public static Status [] bugWorkflow = { Open, Fixed, Wontfix, Duplicate, Invalid, Abandoned, On_Hold, No_Change_Required };
5e3521 1499
4881b8 1500         public static Status [] proposalWorkflow = { Open, Resolved, Declined, Abandoned, On_Hold, No_Change_Required };
706251 1501
JM 1502         public static Status [] milestoneWorkflow = { Open, Closed, Abandoned, On_Hold };
5e3521 1503
JM 1504         @Override
1505         public String toString() {
1506             return name().toLowerCase().replace('_', ' ');
1507         }
1508
1509         public static Status fromObject(Object o, Status defaultStatus) {
1510             if (o instanceof Status) {
1511                 // cast and return
1512                 return (Status) o;
1513             } else if (o instanceof String) {
1514                 // find by name
1515                 String name = o.toString();
1516                 for (Status state : values()) {
1517                     if (state.name().equalsIgnoreCase(name)
1518                             || state.toString().equalsIgnoreCase(name)) {
1519                         return state;
1520                     }
1521                 }
1522             } else if (o instanceof Number) {
1523                 // by ordinal
1524                 int id = ((Number) o).intValue();
1525                 if (id >= 0 && id < values().length) {
1526                     return values()[id];
1527                 }
1528             }
1529
1530             return defaultStatus;
1531         }
1532
1533         public boolean isClosed() {
1534             return ordinal() > Open.ordinal();
1535         }
1536     }
1537
1538     public static enum CommentSource {
1539         Comment, Email
1540     }
1541
1542     public static enum PatchsetType {
cd7e4f 1543         Proposal, FastForward, Rebase, Squash, Rebase_Squash, Amend, Delete;
5e3521 1544
JM 1545         public boolean isRewrite() {
1546             return (this != FastForward) && (this != Proposal);
1547         }
1548
1549         @Override
1550         public String toString() {
1551             return name().toLowerCase().replace('_', '+');
1552         }
1553
1554         public static PatchsetType fromObject(Object o) {
1555             if (o instanceof PatchsetType) {
1556                 // cast and return
1557                 return (PatchsetType) o;
1558             } else if (o instanceof String) {
1559                 // find by name
1560                 String name = o.toString();
1561                 for (PatchsetType type : values()) {
1562                     if (type.name().equalsIgnoreCase(name)
1563                             || type.toString().equalsIgnoreCase(name)) {
1564                         return type;
1565                     }
1566                 }
1567             } else if (o instanceof Number) {
1568                 // by ordinal
1569                 int id = ((Number) o).intValue();
1570                 if (id >= 0 && id < values().length) {
1571                     return values()[id];
1572                 }
1573             }
1574
1575             return null;
1576         }
1577     }
f9c78c 1578
PM 1579     public static enum Priority {
1580         Low(-1), Normal(0), High(1), Urgent(2);
1581
1582         public static Priority defaultPriority = Normal;
1583
1584         final int value;
1585
1586         Priority(int value) {
1587             this.value = value;
1588         }
1589
1590         public int getValue() {
1591             return value;
1592         }
1593         
1594         public static Priority [] choices() {
1595             return new Priority [] { Urgent, High, Normal, Low };
1596         }
1597
1598         @Override
1599         public String toString() {
1600             return name().toLowerCase().replace('_', ' ');
1601         }
1602
1603         public static Priority fromObject(Object o, Priority defaultPriority) {
1604             if (o instanceof Priority) {
1605                 // cast and return
1606                 return (Priority) o;
1607             } else if (o instanceof String) {
1608                 // find by name
1609                 for (Priority priority : values()) {
1610                     String str = o.toString();
1611                     if (priority.name().equalsIgnoreCase(str)
1612                             || priority.toString().equalsIgnoreCase(str)) {
1613                         return priority;
1614                     }
1615                 }
1616             } else if (o instanceof Number) {
1617
1618                 switch (((Number) o).intValue()) {
1619                     case -1: return Priority.Low;
1620                     case 0:  return Priority.Normal;
1621                     case 1:  return Priority.High;
1622                     case 2:  return Priority.Urgent;
1623                     default: return Priority.Normal;
1624                 }
1625             }
1626
1627             return defaultPriority;
1628         }
1629     }
1630     
1631     public static enum Severity {
1632         Unrated(-1), Negligible(1), Minor(2), Serious(3), Critical(4), Catastrophic(5);
1633
1634         public static Severity defaultSeverity = Unrated;
1635         
1636         final int value;
1637         
1638         Severity(int value) {
1639             this.value = value;
1640         }
1641
1642         public int getValue() {
1643             return value;
1644         }
1645         
1646         public static Severity [] choices() {
1647             return new Severity [] { Unrated, Negligible, Minor, Serious, Critical, Catastrophic };
1648         }
1649
1650         @Override
1651         public String toString() {
1652             return name().toLowerCase().replace('_', ' ');
1653         }
1654         
1655         public static Severity fromObject(Object o, Severity defaultSeverity) {
1656             if (o instanceof Severity) {
1657                 // cast and return
1658                 return (Severity) o;
1659             } else if (o instanceof String) {
1660                 // find by name
1661                 for (Severity severity : values()) {
1662                     String str = o.toString();
1663                     if (severity.name().equalsIgnoreCase(str)
1664                             || severity.toString().equalsIgnoreCase(str)) {
1665                         return severity;
1666                     }
1667                 }
1668             } else if (o instanceof Number) {
1669                 
1670                 switch (((Number) o).intValue()) {
1671                     case -1: return Severity.Unrated;
1672                     case 1:  return Severity.Negligible;
1673                     case 2:  return Severity.Minor;
1674                     case 3:  return Severity.Serious;
1675                     case 4:  return Severity.Critical;
1676                     case 5:  return Severity.Catastrophic;
1677                     default: return Severity.Unrated;
1678                 }
1679             }
1680
1681             return defaultSeverity;
1682         }
1683     }
5e3521 1684 }