James Moger
2015-11-22 ed552ba47c02779c270ffd62841d6d1048dade70
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>();
110         for (Change change : changes) {
111             if (change.comment != null) {
112                 if (comments.containsKey(change.comment.id)) {
113                     Change original = comments.get(change.comment.id);
114                     Change clone = copy(original);
115                     clone.comment.text = change.comment.text;
116                     clone.comment.deleted = change.comment.deleted;
117                     int idx = effectiveChanges.indexOf(original);
118                     effectiveChanges.remove(original);
119                     effectiveChanges.add(idx, clone);
120                     comments.put(clone.comment.id, clone);
121                 } else {
122                     effectiveChanges.add(change);
123                     comments.put(change.comment.id, change);
124                 }
125             } else {
126                 effectiveChanges.add(change);
127             }
128         }
129
130         // effective ticket
131         ticket = new TicketModel();
132         for (Change change : effectiveChanges) {
133             if (!change.hasComment()) {
134                 // ensure we do not include a deleted comment
135                 change.comment = null;
136             }
137             ticket.applyChange(change);
138         }
139         return ticket;
140     }
141
142     public TicketModel() {
143         // the first applied change set the date appropriately
144         created = new Date(0);
145         changes = new ArrayList<Change>();
146         status = Status.New;
147         type = Type.defaultType;
f9c78c 148         priority = Priority.defaultPriority;
PM 149         severity = Severity.defaultSeverity;
5e3521 150     }
JM 151
152     public boolean isOpen() {
153         return !status.isClosed();
154     }
155
156     public boolean isClosed() {
157         return status.isClosed();
158     }
159
160     public boolean isMerged() {
161         return isClosed() && !isEmpty(mergeSha);
162     }
163
164     public boolean isProposal() {
165         return Type.Proposal == type;
166     }
167
168     public boolean isBug() {
169         return Type.Bug == type;
170     }
171
172     public Date getLastUpdated() {
173         return updated == null ? created : updated;
174     }
175
176     public boolean hasPatchsets() {
177         return getPatchsets().size() > 0;
178     }
179
180     /**
181      * Returns true if multiple participants are involved in discussing a ticket.
182      * The ticket creator is excluded from this determination because a
183      * discussion requires more than one participant.
184      *
185      * @return true if this ticket has a discussion
186      */
187     public boolean hasDiscussion() {
188         for (Change change : getComments()) {
189             if (!change.author.equals(createdBy)) {
190                 return true;
191             }
192         }
193         return false;
194     }
195
196     /**
197      * Returns the list of changes with comments.
198      *
199      * @return
200      */
201     public List<Change> getComments() {
202         List<Change> list = new ArrayList<Change>();
203         for (Change change : changes) {
204             if (change.hasComment()) {
205                 list.add(change);
206             }
207         }
208         return list;
209     }
210
211     /**
212      * Returns the list of participants for the ticket.
213      *
214      * @return the list of participants
215      */
216     public List<String> getParticipants() {
217         Set<String> set = new LinkedHashSet<String>();
218         for (Change change : changes) {
219             if (change.isParticipantChange()) {
220                 set.add(change.author);
221             }
222         }
223         if (responsible != null && responsible.length() > 0) {
224             set.add(responsible);
225         }
226         return new ArrayList<String>(set);
227     }
228
229     public boolean hasLabel(String label) {
230         return getLabels().contains(label);
231     }
232
233     public List<String> getLabels() {
234         return getList(Field.labels);
235     }
236
237     public boolean isResponsible(String username) {
238         return username.equals(responsible);
239     }
240
241     public boolean isAuthor(String username) {
242         return username.equals(createdBy);
243     }
244
245     public boolean isReviewer(String username) {
246         return getReviewers().contains(username);
247     }
248
249     public List<String> getReviewers() {
250         return getList(Field.reviewers);
251     }
252
253     public boolean isWatching(String username) {
254         return getWatchers().contains(username);
255     }
256
257     public List<String> getWatchers() {
258         return getList(Field.watchers);
259     }
260
261     public boolean isVoter(String username) {
262         return getVoters().contains(username);
263     }
264
265     public List<String> getVoters() {
266         return getList(Field.voters);
267     }
268
269     public List<String> getMentions() {
270         return getList(Field.mentions);
271     }
272
273     protected List<String> getList(Field field) {
274         Set<String> set = new TreeSet<String>();
275         for (Change change : changes) {
276             if (change.hasField(field)) {
277                 String values = change.getString(field);
278                 for (String value : values.split(",")) {
279                     switch (value.charAt(0)) {
280                     case '+':
281                         set.add(value.substring(1));
282                         break;
283                     case '-':
284                         set.remove(value.substring(1));
285                         break;
286                     default:
287                         set.add(value);
288                     }
289                 }
290             }
291         }
292         if (!set.isEmpty()) {
293             return new ArrayList<String>(set);
294         }
295         return Collections.emptyList();
296     }
297
298     public Attachment getAttachment(String name) {
299         Attachment attachment = null;
300         for (Change change : changes) {
301             if (change.hasAttachments()) {
302                 Attachment a = change.getAttachment(name);
303                 if (a != null) {
304                     attachment = a;
305                 }
306             }
307         }
308         return attachment;
309     }
310
311     public boolean hasAttachments() {
312         for (Change change : changes) {
313             if (change.hasAttachments()) {
314                 return true;
315             }
316         }
317         return false;
318     }
319
320     public List<Attachment> getAttachments() {
321         List<Attachment> list = new ArrayList<Attachment>();
322         for (Change change : changes) {
323             if (change.hasAttachments()) {
324                 list.addAll(change.attachments);
325             }
326         }
327         return list;
328     }
329
330     public List<Patchset> getPatchsets() {
331         List<Patchset> list = new ArrayList<Patchset>();
332         for (Change change : changes) {
333             if (change.patchset != null) {
334                 list.add(change.patchset);
335             }
336         }
337         return list;
338     }
339
340     public List<Patchset> getPatchsetRevisions(int number) {
341         List<Patchset> list = new ArrayList<Patchset>();
342         for (Change change : changes) {
343             if (change.patchset != null) {
344                 if (number == change.patchset.number) {
345                     list.add(change.patchset);
346                 }
347             }
348         }
349         return list;
350     }
351
352     public Patchset getPatchset(String sha) {
353         for (Change change : changes) {
354             if (change.patchset != null) {
355                 if (sha.equals(change.patchset.tip)) {
356                     return change.patchset;
357                 }
358             }
359         }
360         return null;
361     }
362
363     public Patchset getPatchset(int number, int rev) {
364         for (Change change : changes) {
365             if (change.patchset != null) {
366                 if (number == change.patchset.number && rev == change.patchset.rev) {
367                     return change.patchset;
368                 }
369             }
370         }
371         return null;
372     }
373
374     public Patchset getCurrentPatchset() {
375         Patchset patchset = null;
376         for (Change change : changes) {
377             if (change.patchset != null) {
378                 if (patchset == null) {
379                     patchset = change.patchset;
380                 } else if (patchset.compareTo(change.patchset) == 1) {
381                     patchset = change.patchset;
382                 }
383             }
384         }
385         return patchset;
386     }
387
388     public boolean isCurrent(Patchset patchset) {
389         if (patchset == null) {
390             return false;
391         }
392         Patchset curr = getCurrentPatchset();
393         if (curr == null) {
394             return false;
395         }
396         return curr.equals(patchset);
397     }
398
399     public List<Change> getReviews(Patchset patchset) {
400         if (patchset == null) {
401             return Collections.emptyList();
402         }
403         // collect the patchset reviews by author
404         // the last review by the author is the
405         // official review
406         Map<String, Change> reviews = new LinkedHashMap<String, TicketModel.Change>();
407         for (Change change : changes) {
408             if (change.hasReview()) {
409                 if (change.review.isReviewOf(patchset)) {
410                     reviews.put(change.author, change);
411                 }
412             }
413         }
414         return new ArrayList<Change>(reviews.values());
415     }
416
417
418     public boolean isApproved(Patchset patchset) {
419         if (patchset == null) {
420             return false;
421         }
422         boolean approved = false;
423         boolean vetoed = false;
424         for (Change change : getReviews(patchset)) {
425             if (change.hasReview()) {
426                 if (change.review.isReviewOf(patchset)) {
427                     if (Score.approved == change.review.score) {
428                         approved = true;
429                     } else if (Score.vetoed == change.review.score) {
430                         vetoed = true;
431                     }
432                 }
433             }
434         }
435         return approved && !vetoed;
436     }
437
438     public boolean isVetoed(Patchset patchset) {
439         if (patchset == null) {
440             return false;
441         }
442         for (Change change : getReviews(patchset)) {
443             if (change.hasReview()) {
444                 if (change.review.isReviewOf(patchset)) {
445                     if (Score.vetoed == change.review.score) {
446                         return true;
447                     }
448                 }
449             }
450         }
451         return false;
452     }
453
454     public Review getReviewBy(String username) {
455         for (Change change : getReviews(getCurrentPatchset())) {
456             if (change.author.equals(username)) {
457                 return change.review;
458             }
459         }
460         return null;
461     }
462
463     public boolean isPatchsetAuthor(String username) {
464         for (Change change : changes) {
465             if (change.hasPatchset()) {
466                 if (change.author.equals(username)) {
467                     return true;
468                 }
469             }
470         }
471         return false;
472     }
473
474     public void applyChange(Change change) {
475         if (changes.size() == 0) {
476             // first change created the ticket
477             created = change.date;
478             createdBy = change.author;
479             status = Status.New;
480         } else if (created == null || change.date.after(created)) {
481             // track last ticket update
482             updated = change.date;
483             updatedBy = change.author;
484         }
485
486         if (change.isMerge()) {
487             // identify merge patchsets
488             if (isEmpty(responsible)) {
489                 responsible = change.author;
490             }
491             status = Status.Merged;
492         }
493
494         if (change.hasFieldChanges()) {
495             for (Map.Entry<Field, String> entry : change.fields.entrySet()) {
496                 Field field = entry.getKey();
497                 Object value = entry.getValue();
498                 switch (field) {
499                 case type:
500                     type = TicketModel.Type.fromObject(value, type);
501                     break;
502                 case status:
503                     status = TicketModel.Status.fromObject(value, status);
504                     break;
505                 case title:
506                     title = toString(value);
507                     break;
508                 case body:
509                     body = toString(value);
510                     break;
511                 case topic:
512                     topic = toString(value);
513                     break;
514                 case responsible:
515                     responsible = toString(value);
516                     break;
517                 case milestone:
518                     milestone = toString(value);
519                     break;
520                 case mergeTo:
521                     mergeTo = toString(value);
522                     break;
523                 case mergeSha:
524                     mergeSha = toString(value);
f9c78c 525                     break;
PM 526                 case priority:
527                     priority = TicketModel.Priority.fromObject(value, priority);
528                     break;
529                 case severity:
530                     severity = TicketModel.Severity.fromObject(value, severity);
5e3521 531                     break;
JM 532                 default:
533                     // unknown
534                     break;
535                 }
536             }
537         }
538
539         // add the change to the ticket
540         changes.add(change);
541     }
542
543     protected String toString(Object value) {
544         if (value == null) {
545             return null;
546         }
547         return value.toString();
548     }
549
550     public String toIndexableString() {
551         StringBuilder sb = new StringBuilder();
552         if (!isEmpty(title)) {
553             sb.append(title).append('\n');
554         }
555         if (!isEmpty(body)) {
556             sb.append(body).append('\n');
557         }
558         for (Change change : changes) {
559             if (change.hasComment()) {
560                 sb.append(change.comment.text);
561                 sb.append('\n');
562             }
563         }
564         return sb.toString();
565     }
566
567     @Override
568     public String toString() {
569         StringBuilder sb = new StringBuilder();
570         sb.append("#");
571         sb.append(number);
572         sb.append(": " + title + "\n");
573         for (Change change : changes) {
574             sb.append(change);
575             sb.append('\n');
576         }
577         return sb.toString();
578     }
579
580     @Override
581     public int compareTo(TicketModel o) {
582         return o.created.compareTo(created);
583     }
584
585     @Override
586     public boolean equals(Object o) {
587         if (o instanceof TicketModel) {
588             return number == ((TicketModel) o).number;
589         }
590         return super.equals(o);
591     }
592
593     @Override
594     public int hashCode() {
595         return (repository + number).hashCode();
596     }
597
598     /**
599      * Encapsulates a ticket change
600      */
601     public static class Change implements Serializable, Comparable<Change> {
602
603         private static final long serialVersionUID = 1L;
604
605         public final Date date;
606
607         public final String author;
608
609         public Comment comment;
610
611         public Map<Field, String> fields;
612
613         public Set<Attachment> attachments;
614
615         public Patchset patchset;
616
617         public Review review;
618
619         private transient String id;
620
621         public Change(String author) {
622             this(author, new Date());
623         }
624
625         public Change(String author, Date date) {
626             this.date = date;
627             this.author = author;
628         }
629
630         public boolean isStatusChange() {
631             return hasField(Field.status);
632         }
633
634         public Status getStatus() {
635             Status state = Status.fromObject(getField(Field.status), null);
636             return state;
637         }
638
639         public boolean isMerge() {
640             return hasField(Field.status) && hasField(Field.mergeSha);
641         }
642
643         public boolean hasPatchset() {
644             return patchset != null;
645         }
646
647         public boolean hasReview() {
648             return review != null;
649         }
650
651         public boolean hasComment() {
fafeb6 652             return comment != null && !comment.isDeleted() && comment.text != null;
5e3521 653         }
JM 654
655         public Comment comment(String text) {
656             comment = new Comment(text);
657             comment.id = TicketModel.getSHA1(date.toString() + author + text);
658
659             try {
660                 Pattern mentions = Pattern.compile("\\s@([A-Za-z0-9-_]+)");
661                 Matcher m = mentions.matcher(text);
662                 while (m.find()) {
663                     String username = m.group(1);
664                     plusList(Field.mentions, username);
665                 }
666             } catch (Exception e) {
667                 // ignore
668             }
669             return comment;
670         }
671
672         public Review review(Patchset patchset, Score score, boolean addReviewer) {
673             if (addReviewer) {
674                 plusList(Field.reviewers, author);
675             }
676             review = new Review(patchset.number, patchset.rev);
677             review.score = score;
678             return review;
679         }
680
681         public boolean hasAttachments() {
682             return !TicketModel.isEmpty(attachments);
683         }
684
685         public void addAttachment(Attachment attachment) {
686             if (attachments == null) {
687                 attachments = new LinkedHashSet<Attachment>();
688             }
689             attachments.add(attachment);
690         }
691
692         public Attachment getAttachment(String name) {
693             if (attachments != null) {
694                 for (Attachment attachment : attachments) {
695                     if (attachment.name.equalsIgnoreCase(name)) {
696                         return attachment;
697                     }
698                 }
699             }
700             return null;
701         }
702
703         public boolean isParticipantChange() {
704             if (hasComment()
705                     || hasReview()
706                     || hasPatchset()
707                     || hasAttachments()) {
708                 return true;
709             }
710
711             if (TicketModel.isEmpty(fields)) {
712                 return false;
713             }
714
715             // identify real ticket field changes
716             Map<Field, String> map = new HashMap<Field, String>(fields);
717             map.remove(Field.watchers);
718             map.remove(Field.voters);
719             return !map.isEmpty();
720         }
721
722         public boolean hasField(Field field) {
723             return !TicketModel.isEmpty(getString(field));
724         }
725
726         public boolean hasFieldChanges() {
727             return !TicketModel.isEmpty(fields);
728         }
729
730         public String getField(Field field) {
731             if (fields != null) {
732                 return fields.get(field);
733             }
734             return null;
735         }
736
737         public void setField(Field field, Object value) {
738             if (fields == null) {
739                 fields = new LinkedHashMap<Field, String>();
740             }
741             if (value == null) {
742                 fields.put(field, null);
743             } else if (Enum.class.isAssignableFrom(value.getClass())) {
744                 fields.put(field, ((Enum<?>) value).name());
745             } else {
746                 fields.put(field, value.toString());
747             }
748         }
749
750         public void remove(Field field) {
751             if (fields != null) {
752                 fields.remove(field);
753             }
754         }
755
756         public String getString(Field field) {
757             String value = getField(field);
758             if (value == null) {
759                 return null;
760             }
761             return value;
762         }
763
764         public void watch(String... username) {
765             plusList(Field.watchers, username);
766         }
767
768         public void unwatch(String... username) {
769             minusList(Field.watchers, username);
770         }
771
772         public void vote(String... username) {
773             plusList(Field.voters, username);
774         }
775
776         public void unvote(String... username) {
777             minusList(Field.voters, username);
778         }
779
780         public void label(String... label) {
781             plusList(Field.labels, label);
782         }
783
784         public void unlabel(String... label) {
785             minusList(Field.labels, label);
786         }
787
788         protected void plusList(Field field, String... items) {
789             modList(field, "+", items);
790         }
791
792         protected void minusList(Field field, String... items) {
793             modList(field, "-", items);
794         }
795
796         private void modList(Field field, String prefix, String... items) {
797             List<String> list = new ArrayList<String>();
798             for (String item : items) {
799                 list.add(prefix + item);
800             }
120794 801             if (hasField(field)) {
JM 802                 String flat = getString(field);
803                 if (isEmpty(flat)) {
804                     // field is empty, use this list
805                     setField(field, join(list, ","));
806                 } else {
807                     // merge this list into the existing field list
808                     Set<String> set = new TreeSet<String>(Arrays.asList(flat.split(",")));
809                     set.addAll(list);
810                     setField(field, join(set, ","));
811                 }
812             } else {
813                 // does not have a list for this field
814                 setField(field, join(list, ","));
815             }
5e3521 816         }
JM 817
818         public String getId() {
819             if (id == null) {
820                 id = getSHA1(Long.toHexString(date.getTime()) + author);
821             }
822             return id;
823         }
824
825         @Override
826         public int compareTo(Change c) {
827             return date.compareTo(c.date);
828         }
829
830         @Override
831         public int hashCode() {
832             return getId().hashCode();
833         }
834
835         @Override
836         public boolean equals(Object o) {
837             if (o instanceof Change) {
838                 return getId().equals(((Change) o).getId());
839             }
840             return false;
841         }
842
843         @Override
844         public String toString() {
845             StringBuilder sb = new StringBuilder();
846             sb.append(RelativeDateFormatter.format(date));
847             if (hasComment()) {
848                 sb.append(" commented on by ");
849             } else if (hasPatchset()) {
850                 sb.append(MessageFormat.format(" {0} uploaded by ", patchset));
851             } else {
852                 sb.append(" changed by ");
853             }
854             sb.append(author).append(" - ");
855             if (hasComment()) {
856                 if (comment.isDeleted()) {
857                     sb.append("(deleted) ");
858                 }
859                 sb.append(comment.text).append(" ");
860             }
861
862             if (hasFieldChanges()) {
863                 for (Map.Entry<Field, String> entry : fields.entrySet()) {
864                     sb.append("\n  ");
865                     sb.append(entry.getKey().name());
866                     sb.append(':');
867                     sb.append(entry.getValue());
868                 }
869             }
870             return sb.toString();
871         }
872     }
873
874     /**
875      * Returns true if the string is null or empty.
876      *
877      * @param value
878      * @return true if string is null or empty
879      */
880     static boolean isEmpty(String value) {
881         return value == null || value.trim().length() == 0;
882     }
883
884     /**
885      * Returns true if the collection is null or empty
886      *
887      * @param collection
888      * @return
889      */
890     static boolean isEmpty(Collection<?> collection) {
891         return collection == null || collection.size() == 0;
892     }
893
894     /**
895      * Returns true if the map is null or empty
896      *
897      * @param map
898      * @return
899      */
900     static boolean isEmpty(Map<?, ?> map) {
901         return map == null || map.size() == 0;
902     }
903
904     /**
905      * Calculates the SHA1 of the string.
906      *
907      * @param text
908      * @return sha1 of the string
909      */
910     static String getSHA1(String text) {
911         try {
912             byte[] bytes = text.getBytes("iso-8859-1");
913             return getSHA1(bytes);
914         } catch (UnsupportedEncodingException u) {
915             throw new RuntimeException(u);
916         }
917     }
918
919     /**
920      * Calculates the SHA1 of the byte array.
921      *
922      * @param bytes
923      * @return sha1 of the byte array
924      */
925     static String getSHA1(byte[] bytes) {
926         try {
927             MessageDigest md = MessageDigest.getInstance("SHA-1");
928             md.update(bytes, 0, bytes.length);
929             byte[] digest = md.digest();
930             return toHex(digest);
931         } catch (NoSuchAlgorithmException t) {
932             throw new RuntimeException(t);
933         }
934     }
935
936     /**
937      * Returns the hex representation of the byte array.
938      *
939      * @param bytes
940      * @return byte array as hex string
941      */
942     static String toHex(byte[] bytes) {
943         StringBuilder sb = new StringBuilder(bytes.length * 2);
944         for (int i = 0; i < bytes.length; i++) {
945             if ((bytes[i] & 0xff) < 0x10) {
946                 sb.append('0');
947             }
948             sb.append(Long.toString(bytes[i] & 0xff, 16));
949         }
950         return sb.toString();
951     }
952
953     /**
954      * Join the list of strings into a single string with a space separator.
955      *
956      * @param values
957      * @return joined list
958      */
959     static String join(Collection<String> values) {
960         return join(values, " ");
961     }
962
963     /**
964      * Join the list of strings into a single string with the specified
965      * separator.
966      *
967      * @param values
968      * @param separator
969      * @return joined list
970      */
971     static String join(String[]  values, String separator) {
972         return join(Arrays.asList(values), separator);
973     }
974
975     /**
976      * Join the list of strings into a single string with the specified
977      * separator.
978      *
979      * @param values
980      * @param separator
981      * @return joined list
982      */
983     static String join(Collection<String> values, String separator) {
984         StringBuilder sb = new StringBuilder();
985         for (String value : values) {
986             sb.append(value).append(separator);
987         }
988         if (sb.length() > 0) {
989             // truncate trailing separator
990             sb.setLength(sb.length() - separator.length());
991         }
992         return sb.toString().trim();
993     }
994
995
996     /**
997      * Produce a deep copy of the given object. Serializes the entire object to
998      * a byte array in memory. Recommended for relatively small objects.
999      */
1000     @SuppressWarnings("unchecked")
1001     static <T> T copy(T original) {
1002         T o = null;
1003         try {
1004             ByteArrayOutputStream byteOut = new ByteArrayOutputStream();
1005             ObjectOutputStream oos = new ObjectOutputStream(byteOut);
1006             oos.writeObject(original);
1007             ByteArrayInputStream byteIn = new ByteArrayInputStream(byteOut.toByteArray());
1008             ObjectInputStream ois = new ObjectInputStream(byteIn);
1009             try {
1010                 o = (T) ois.readObject();
1011             } catch (ClassNotFoundException cex) {
1012                 // actually can not happen in this instance
1013             }
1014         } catch (IOException iox) {
1015             // doesn't seem likely to happen as these streams are in memory
1016             throw new RuntimeException(iox);
1017         }
1018         return o;
1019     }
1020
1021     public static class Patchset implements Serializable, Comparable<Patchset> {
1022
1023         private static final long serialVersionUID = 1L;
1024
1025         public int number;
1026         public int rev;
1027         public String tip;
1028         public String parent;
1029         public String base;
1030         public int insertions;
1031         public int deletions;
1032         public int commits;
1033         public int added;
1034         public PatchsetType type;
1035
1036         public boolean isFF() {
1037             return PatchsetType.FastForward == type;
1038         }
1039
1040         @Override
1041         public int hashCode() {
1042             return toString().hashCode();
1043         }
1044
1045         @Override
1046         public boolean equals(Object o) {
1047             if (o instanceof Patchset) {
1048                 return hashCode() == o.hashCode();
1049             }
1050             return false;
1051         }
1052
1053         @Override
1054         public int compareTo(Patchset p) {
1055             if (number > p.number) {
1056                 return -1;
1057             } else if (p.number > number) {
1058                 return 1;
1059             } else {
1060                 // same patchset, different revision
1061                 if (rev > p.rev) {
1062                     return -1;
1063                 } else if (p.rev > rev) {
1064                     return 1;
1065                 } else {
1066                     // same patchset & revision
1067                     return 0;
1068                 }
1069             }
1070         }
1071
1072         @Override
1073         public String toString() {
1074             return "patchset " + number + " revision " + rev;
1075         }
1076     }
1077
1078     public static class Comment implements Serializable {
1079
1080         private static final long serialVersionUID = 1L;
1081
1082         public String text;
1083
1084         public String id;
1085
1086         public Boolean deleted;
1087
1088         public CommentSource src;
1089
1090         public String replyTo;
1091
1092         Comment(String text) {
1093             this.text = text;
1094         }
1095
1096         public boolean isDeleted() {
1097             return deleted != null && deleted;
1098         }
1099
1100         @Override
1101         public String toString() {
1102             return text;
1103         }
1104     }
1105
1106     public static class Attachment implements Serializable {
1107
1108         private static final long serialVersionUID = 1L;
1109
1110         public final String name;
1111         public long size;
1112         public byte[] content;
1113         public Boolean deleted;
1114
1115         public Attachment(String name) {
1116             this.name = name;
1117         }
1118
1119         public boolean isDeleted() {
1120             return deleted != null && deleted;
1121         }
1122
1123         @Override
1124         public int hashCode() {
1125             return name.hashCode();
1126         }
1127
1128         @Override
1129         public boolean equals(Object o) {
1130             if (o instanceof Attachment) {
1131                 return name.equalsIgnoreCase(((Attachment) o).name);
1132             }
1133             return false;
1134         }
1135
1136         @Override
1137         public String toString() {
1138             return name;
1139         }
1140     }
1141
1142     public static class Review implements Serializable {
1143
1144         private static final long serialVersionUID = 1L;
1145
1146         public final int patchset;
1147
1148         public final int rev;
1149
1150         public Score score;
1151
1152         public Review(int patchset, int revision) {
1153             this.patchset = patchset;
1154             this.rev = revision;
1155         }
1156
1157         public boolean isReviewOf(Patchset p) {
1158             return patchset == p.number && rev == p.rev;
1159         }
1160
1161         @Override
1162         public String toString() {
1163             return "review of patchset " + patchset + " rev " + rev + ":" + score;
1164         }
1165     }
1166
1167     public static enum Score {
b799d5 1168         approved(2), looks_good(1), not_reviewed(0), needs_improvement(-1), vetoed(
DO 1169                 -2);
5e3521 1170
JM 1171         final int value;
1172
1173         Score(int value) {
1174             this.value = value;
1175         }
1176
1177         public int getValue() {
1178             return value;
1179         }
1180
1181         @Override
1182         public String toString() {
1183             return name().toLowerCase().replace('_', ' ');
1184         }
b799d5 1185
DO 1186         public static Score fromScore(int score) {
1187             for (Score s : values()) {
1188                 if (s.getValue() == score) {
1189                     return s;
1190                 }
1191             }
1192             throw new NoSuchElementException(String.valueOf(score));
1193         }
5e3521 1194     }
JM 1195
1196     public static enum Field {
1197         title, body, responsible, type, status, milestone, mergeSha, mergeTo,
f9c78c 1198         topic, labels, watchers, reviewers, voters, mentions, priority, severity;
5e3521 1199     }
JM 1200
1201     public static enum Type {
f5d568 1202         Enhancement, Task, Bug, Proposal, Question, Maintenance;
5e3521 1203
JM 1204         public static Type defaultType = Task;
1205
1206         public static Type [] choices() {
f5d568 1207             return new Type [] { Enhancement, Task, Bug, Question, Maintenance };
5e3521 1208         }
JM 1209
1210         @Override
1211         public String toString() {
1212             return name().toLowerCase().replace('_', ' ');
1213         }
1214
1215         public static Type fromObject(Object o, Type defaultType) {
1216             if (o instanceof Type) {
1217                 // cast and return
1218                 return (Type) o;
1219             } else if (o instanceof String) {
1220                 // find by name
1221                 for (Type type : values()) {
1222                     String str = o.toString();
1223                     if (type.name().equalsIgnoreCase(str)
1224                             || type.toString().equalsIgnoreCase(str)) {
1225                         return type;
1226                     }
1227                 }
1228             } else if (o instanceof Number) {
1229                 // by ordinal
1230                 int id = ((Number) o).intValue();
1231                 if (id >= 0 && id < values().length) {
1232                     return values()[id];
1233                 }
1234             }
1235
1236             return defaultType;
1237         }
1238     }
1239
1240     public static enum Status {
4881b8 1241         New, Open, Closed, Resolved, Fixed, Merged, Wontfix, Declined, Duplicate, Invalid, Abandoned, On_Hold, No_Change_Required;
5e3521 1242
4881b8 1243         public static Status [] requestWorkflow = { Open, Resolved, Declined, Duplicate, Invalid, Abandoned, On_Hold, No_Change_Required };
5e3521 1244
4881b8 1245         public static Status [] bugWorkflow = { Open, Fixed, Wontfix, Duplicate, Invalid, Abandoned, On_Hold, No_Change_Required };
5e3521 1246
4881b8 1247         public static Status [] proposalWorkflow = { Open, Resolved, Declined, Abandoned, On_Hold, No_Change_Required };
706251 1248
JM 1249         public static Status [] milestoneWorkflow = { Open, Closed, Abandoned, On_Hold };
5e3521 1250
JM 1251         @Override
1252         public String toString() {
1253             return name().toLowerCase().replace('_', ' ');
1254         }
1255
1256         public static Status fromObject(Object o, Status defaultStatus) {
1257             if (o instanceof Status) {
1258                 // cast and return
1259                 return (Status) o;
1260             } else if (o instanceof String) {
1261                 // find by name
1262                 String name = o.toString();
1263                 for (Status state : values()) {
1264                     if (state.name().equalsIgnoreCase(name)
1265                             || state.toString().equalsIgnoreCase(name)) {
1266                         return state;
1267                     }
1268                 }
1269             } else if (o instanceof Number) {
1270                 // by ordinal
1271                 int id = ((Number) o).intValue();
1272                 if (id >= 0 && id < values().length) {
1273                     return values()[id];
1274                 }
1275             }
1276
1277             return defaultStatus;
1278         }
1279
1280         public boolean isClosed() {
1281             return ordinal() > Open.ordinal();
1282         }
1283     }
1284
1285     public static enum CommentSource {
1286         Comment, Email
1287     }
1288
1289     public static enum PatchsetType {
1290         Proposal, FastForward, Rebase, Squash, Rebase_Squash, Amend;
1291
1292         public boolean isRewrite() {
1293             return (this != FastForward) && (this != Proposal);
1294         }
1295
1296         @Override
1297         public String toString() {
1298             return name().toLowerCase().replace('_', '+');
1299         }
1300
1301         public static PatchsetType fromObject(Object o) {
1302             if (o instanceof PatchsetType) {
1303                 // cast and return
1304                 return (PatchsetType) o;
1305             } else if (o instanceof String) {
1306                 // find by name
1307                 String name = o.toString();
1308                 for (PatchsetType type : values()) {
1309                     if (type.name().equalsIgnoreCase(name)
1310                             || type.toString().equalsIgnoreCase(name)) {
1311                         return type;
1312                     }
1313                 }
1314             } else if (o instanceof Number) {
1315                 // by ordinal
1316                 int id = ((Number) o).intValue();
1317                 if (id >= 0 && id < values().length) {
1318                     return values()[id];
1319                 }
1320             }
1321
1322             return null;
1323         }
1324     }
f9c78c 1325
PM 1326     public static enum Priority {
1327         Low(-1), Normal(0), High(1), Urgent(2);
1328
1329         public static Priority defaultPriority = Normal;
1330
1331         final int value;
1332
1333         Priority(int value) {
1334             this.value = value;
1335         }
1336
1337         public int getValue() {
1338             return value;
1339         }
1340         
1341         public static Priority [] choices() {
1342             return new Priority [] { Urgent, High, Normal, Low };
1343         }
1344
1345         @Override
1346         public String toString() {
1347             return name().toLowerCase().replace('_', ' ');
1348         }
1349
1350         public static Priority fromObject(Object o, Priority defaultPriority) {
1351             if (o instanceof Priority) {
1352                 // cast and return
1353                 return (Priority) o;
1354             } else if (o instanceof String) {
1355                 // find by name
1356                 for (Priority priority : values()) {
1357                     String str = o.toString();
1358                     if (priority.name().equalsIgnoreCase(str)
1359                             || priority.toString().equalsIgnoreCase(str)) {
1360                         return priority;
1361                     }
1362                 }
1363             } else if (o instanceof Number) {
1364
1365                 switch (((Number) o).intValue()) {
1366                     case -1: return Priority.Low;
1367                     case 0:  return Priority.Normal;
1368                     case 1:  return Priority.High;
1369                     case 2:  return Priority.Urgent;
1370                     default: return Priority.Normal;
1371                 }
1372             }
1373
1374             return defaultPriority;
1375         }
1376     }
1377     
1378     public static enum Severity {
1379         Unrated(-1), Negligible(1), Minor(2), Serious(3), Critical(4), Catastrophic(5);
1380
1381         public static Severity defaultSeverity = Unrated;
1382         
1383         final int value;
1384         
1385         Severity(int value) {
1386             this.value = value;
1387         }
1388
1389         public int getValue() {
1390             return value;
1391         }
1392         
1393         public static Severity [] choices() {
1394             return new Severity [] { Unrated, Negligible, Minor, Serious, Critical, Catastrophic };
1395         }
1396
1397         @Override
1398         public String toString() {
1399             return name().toLowerCase().replace('_', ' ');
1400         }
1401         
1402         public static Severity fromObject(Object o, Severity defaultSeverity) {
1403             if (o instanceof Severity) {
1404                 // cast and return
1405                 return (Severity) o;
1406             } else if (o instanceof String) {
1407                 // find by name
1408                 for (Severity severity : values()) {
1409                     String str = o.toString();
1410                     if (severity.name().equalsIgnoreCase(str)
1411                             || severity.toString().equalsIgnoreCase(str)) {
1412                         return severity;
1413                     }
1414                 }
1415             } else if (o instanceof Number) {
1416                 
1417                 switch (((Number) o).intValue()) {
1418                     case -1: return Severity.Unrated;
1419                     case 1:  return Severity.Negligible;
1420                     case 2:  return Severity.Minor;
1421                     case 3:  return Severity.Serious;
1422                     case 4:  return Severity.Critical;
1423                     case 5:  return Severity.Catastrophic;
1424                     default: return Severity.Unrated;
1425                 }
1426             }
1427
1428             return defaultSeverity;
1429         }
1430     }
5e3521 1431 }