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