James Moger
2013-12-09 5e3521f8496511db4df45f011ea72f25623ad90f
src/main/java/com/gitblit/models/TicketModel.java
@@ -1,119 +1,1286 @@
/*
 * Copyright 2011 gitblit.com.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.gitblit.models;
import java.io.Serializable;
import java.text.ParseException;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
/**
 * TicketModel is a serializable model class that represents a Ticgit ticket.
 *
 * @author James Moger
 *
 */
public class TicketModel implements Serializable, Comparable<TicketModel> {
   private static final long serialVersionUID = 1L;
   public String id;
   public String name;
   public String title;
   public String state;
   public Date date;
   public String handler;
   public String milestone;
   public String email;
   public String author;
   public List<Comment> comments;
   public List<String> tags;
   public TicketModel(String ticketName) throws ParseException {
      state = "";
      name = ticketName;
      comments = new ArrayList<Comment>();
      tags = new ArrayList<String>();
      String[] chunks = name.split("_");
      if (chunks.length == 3) {
         date = new Date(Long.parseLong(chunks[0]) * 1000L);
         title = chunks[1].replace('-', ' ');
      }
   }
   @Override
   public int hashCode() {
      return id.hashCode();
   }
   @Override
   public boolean equals(Object o) {
      if (o instanceof TicketModel) {
         TicketModel other = (TicketModel) o;
         return id.equals(other.id);
      }
      return super.equals(o);
   }
   @Override
   public int compareTo(TicketModel o) {
      return date.compareTo(o.date);
   }
   /**
    * Comment is a serializable model class that represents a Ticgit ticket
    * comment.
    *
    * @author James Moger
    *
    */
   public static class Comment implements Serializable, Comparable<Comment> {
      private static final long serialVersionUID = 1L;
      public String text;
      public String author;
      public Date date;
      public Comment(String filename, String content) throws ParseException {
         String[] chunks = filename.split("_", -1);
         this.date = new Date(Long.parseLong(chunks[1]) * 1000L);
         this.author = chunks[2];
         this.text = content;
      }
      @Override
      public int hashCode() {
         return text.hashCode();
      }
      @Override
      public boolean equals(Object o) {
         if (o instanceof Comment) {
            Comment other = (Comment) o;
            return text.equals(other.text);
         }
         return super.equals(o);
      }
      @Override
      public int compareTo(Comment o) {
         return date.compareTo(o.date);
      }
   }
}
/*
 * Copyright 2014 gitblit.com.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.gitblit.models;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;
import java.io.UnsupportedEncodingException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.text.MessageFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.eclipse.jgit.util.RelativeDateFormatter;
/**
 * The Gitblit Ticket model, its component classes, and enums.
 *
 * @author James Moger
 *
 */
public class TicketModel implements Serializable, Comparable<TicketModel> {
   private static final long serialVersionUID = 1L;
   public String project;
   public String repository;
   public long number;
   public Date created;
   public String createdBy;
   public Date updated;
   public String updatedBy;
   public String title;
   public String body;
   public String topic;
   public Type type;
   public Status status;
   public String responsible;
   public String milestone;
   public String mergeSha;
   public String mergeTo;
   public List<Change> changes;
   public Integer insertions;
   public Integer deletions;
   /**
    * Builds an effective ticket from the collection of changes.  A change may
    * Add or Subtract information from a ticket, but the collection of changes
    * is only additive.
    *
    * @param changes
    * @return the effective ticket
    */
   public static TicketModel buildTicket(Collection<Change> changes) {
      TicketModel ticket;
      List<Change> effectiveChanges = new ArrayList<Change>();
      Map<String, Change> comments = new HashMap<String, Change>();
      for (Change change : changes) {
         if (change.comment != null) {
            if (comments.containsKey(change.comment.id)) {
               Change original = comments.get(change.comment.id);
               Change clone = copy(original);
               clone.comment.text = change.comment.text;
               clone.comment.deleted = change.comment.deleted;
               int idx = effectiveChanges.indexOf(original);
               effectiveChanges.remove(original);
               effectiveChanges.add(idx, clone);
               comments.put(clone.comment.id, clone);
            } else {
               effectiveChanges.add(change);
               comments.put(change.comment.id, change);
            }
         } else {
            effectiveChanges.add(change);
         }
      }
      // effective ticket
      ticket = new TicketModel();
      for (Change change : effectiveChanges) {
         if (!change.hasComment()) {
            // ensure we do not include a deleted comment
            change.comment = null;
         }
         ticket.applyChange(change);
      }
      return ticket;
   }
   public TicketModel() {
      // the first applied change set the date appropriately
      created = new Date(0);
      changes = new ArrayList<Change>();
      status = Status.New;
      type = Type.defaultType;
   }
   public boolean isOpen() {
      return !status.isClosed();
   }
   public boolean isClosed() {
      return status.isClosed();
   }
   public boolean isMerged() {
      return isClosed() && !isEmpty(mergeSha);
   }
   public boolean isProposal() {
      return Type.Proposal == type;
   }
   public boolean isBug() {
      return Type.Bug == type;
   }
   public Date getLastUpdated() {
      return updated == null ? created : updated;
   }
   public boolean hasPatchsets() {
      return getPatchsets().size() > 0;
   }
   /**
    * Returns true if multiple participants are involved in discussing a ticket.
    * The ticket creator is excluded from this determination because a
    * discussion requires more than one participant.
    *
    * @return true if this ticket has a discussion
    */
   public boolean hasDiscussion() {
      for (Change change : getComments()) {
         if (!change.author.equals(createdBy)) {
            return true;
         }
      }
      return false;
   }
   /**
    * Returns the list of changes with comments.
    *
    * @return
    */
   public List<Change> getComments() {
      List<Change> list = new ArrayList<Change>();
      for (Change change : changes) {
         if (change.hasComment()) {
            list.add(change);
         }
      }
      return list;
   }
   /**
    * Returns the list of participants for the ticket.
    *
    * @return the list of participants
    */
   public List<String> getParticipants() {
      Set<String> set = new LinkedHashSet<String>();
      for (Change change : changes) {
         if (change.isParticipantChange()) {
            set.add(change.author);
         }
      }
      if (responsible != null && responsible.length() > 0) {
         set.add(responsible);
      }
      return new ArrayList<String>(set);
   }
   public boolean hasLabel(String label) {
      return getLabels().contains(label);
   }
   public List<String> getLabels() {
      return getList(Field.labels);
   }
   public boolean isResponsible(String username) {
      return username.equals(responsible);
   }
   public boolean isAuthor(String username) {
      return username.equals(createdBy);
   }
   public boolean isReviewer(String username) {
      return getReviewers().contains(username);
   }
   public List<String> getReviewers() {
      return getList(Field.reviewers);
   }
   public boolean isWatching(String username) {
      return getWatchers().contains(username);
   }
   public List<String> getWatchers() {
      return getList(Field.watchers);
   }
   public boolean isVoter(String username) {
      return getVoters().contains(username);
   }
   public List<String> getVoters() {
      return getList(Field.voters);
   }
   public List<String> getMentions() {
      return getList(Field.mentions);
   }
   protected List<String> getList(Field field) {
      Set<String> set = new TreeSet<String>();
      for (Change change : changes) {
         if (change.hasField(field)) {
            String values = change.getString(field);
            for (String value : values.split(",")) {
               switch (value.charAt(0)) {
               case '+':
                  set.add(value.substring(1));
                  break;
               case '-':
                  set.remove(value.substring(1));
                  break;
               default:
                  set.add(value);
               }
            }
         }
      }
      if (!set.isEmpty()) {
         return new ArrayList<String>(set);
      }
      return Collections.emptyList();
   }
   public Attachment getAttachment(String name) {
      Attachment attachment = null;
      for (Change change : changes) {
         if (change.hasAttachments()) {
            Attachment a = change.getAttachment(name);
            if (a != null) {
               attachment = a;
            }
         }
      }
      return attachment;
   }
   public boolean hasAttachments() {
      for (Change change : changes) {
         if (change.hasAttachments()) {
            return true;
         }
      }
      return false;
   }
   public List<Attachment> getAttachments() {
      List<Attachment> list = new ArrayList<Attachment>();
      for (Change change : changes) {
         if (change.hasAttachments()) {
            list.addAll(change.attachments);
         }
      }
      return list;
   }
   public List<Patchset> getPatchsets() {
      List<Patchset> list = new ArrayList<Patchset>();
      for (Change change : changes) {
         if (change.patchset != null) {
            list.add(change.patchset);
         }
      }
      return list;
   }
   public List<Patchset> getPatchsetRevisions(int number) {
      List<Patchset> list = new ArrayList<Patchset>();
      for (Change change : changes) {
         if (change.patchset != null) {
            if (number == change.patchset.number) {
               list.add(change.patchset);
            }
         }
      }
      return list;
   }
   public Patchset getPatchset(String sha) {
      for (Change change : changes) {
         if (change.patchset != null) {
            if (sha.equals(change.patchset.tip)) {
               return change.patchset;
            }
         }
      }
      return null;
   }
   public Patchset getPatchset(int number, int rev) {
      for (Change change : changes) {
         if (change.patchset != null) {
            if (number == change.patchset.number && rev == change.patchset.rev) {
               return change.patchset;
            }
         }
      }
      return null;
   }
   public Patchset getCurrentPatchset() {
      Patchset patchset = null;
      for (Change change : changes) {
         if (change.patchset != null) {
            if (patchset == null) {
               patchset = change.patchset;
            } else if (patchset.compareTo(change.patchset) == 1) {
               patchset = change.patchset;
            }
         }
      }
      return patchset;
   }
   public boolean isCurrent(Patchset patchset) {
      if (patchset == null) {
         return false;
      }
      Patchset curr = getCurrentPatchset();
      if (curr == null) {
         return false;
      }
      return curr.equals(patchset);
   }
   public List<Change> getReviews(Patchset patchset) {
      if (patchset == null) {
         return Collections.emptyList();
      }
      // collect the patchset reviews by author
      // the last review by the author is the
      // official review
      Map<String, Change> reviews = new LinkedHashMap<String, TicketModel.Change>();
      for (Change change : changes) {
         if (change.hasReview()) {
            if (change.review.isReviewOf(patchset)) {
               reviews.put(change.author, change);
            }
         }
      }
      return new ArrayList<Change>(reviews.values());
   }
   public boolean isApproved(Patchset patchset) {
      if (patchset == null) {
         return false;
      }
      boolean approved = false;
      boolean vetoed = false;
      for (Change change : getReviews(patchset)) {
         if (change.hasReview()) {
            if (change.review.isReviewOf(patchset)) {
               if (Score.approved == change.review.score) {
                  approved = true;
               } else if (Score.vetoed == change.review.score) {
                  vetoed = true;
               }
            }
         }
      }
      return approved && !vetoed;
   }
   public boolean isVetoed(Patchset patchset) {
      if (patchset == null) {
         return false;
      }
      for (Change change : getReviews(patchset)) {
         if (change.hasReview()) {
            if (change.review.isReviewOf(patchset)) {
               if (Score.vetoed == change.review.score) {
                  return true;
               }
            }
         }
      }
      return false;
   }
   public Review getReviewBy(String username) {
      for (Change change : getReviews(getCurrentPatchset())) {
         if (change.author.equals(username)) {
            return change.review;
         }
      }
      return null;
   }
   public boolean isPatchsetAuthor(String username) {
      for (Change change : changes) {
         if (change.hasPatchset()) {
            if (change.author.equals(username)) {
               return true;
            }
         }
      }
      return false;
   }
   public void applyChange(Change change) {
      if (changes.size() == 0) {
         // first change created the ticket
         created = change.date;
         createdBy = change.author;
         status = Status.New;
      } else if (created == null || change.date.after(created)) {
         // track last ticket update
         updated = change.date;
         updatedBy = change.author;
      }
      if (change.isMerge()) {
         // identify merge patchsets
         if (isEmpty(responsible)) {
            responsible = change.author;
         }
         status = Status.Merged;
      }
      if (change.hasFieldChanges()) {
         for (Map.Entry<Field, String> entry : change.fields.entrySet()) {
            Field field = entry.getKey();
            Object value = entry.getValue();
            switch (field) {
            case type:
               type = TicketModel.Type.fromObject(value, type);
               break;
            case status:
               status = TicketModel.Status.fromObject(value, status);
               break;
            case title:
               title = toString(value);
               break;
            case body:
               body = toString(value);
               break;
            case topic:
               topic = toString(value);
               break;
            case responsible:
               responsible = toString(value);
               break;
            case milestone:
               milestone = toString(value);
               break;
            case mergeTo:
               mergeTo = toString(value);
               break;
            case mergeSha:
               mergeSha = toString(value);
               break;
            default:
               // unknown
               break;
            }
         }
      }
      // add the change to the ticket
      changes.add(change);
   }
   protected String toString(Object value) {
      if (value == null) {
         return null;
      }
      return value.toString();
   }
   public String toIndexableString() {
      StringBuilder sb = new StringBuilder();
      if (!isEmpty(title)) {
         sb.append(title).append('\n');
      }
      if (!isEmpty(body)) {
         sb.append(body).append('\n');
      }
      for (Change change : changes) {
         if (change.hasComment()) {
            sb.append(change.comment.text);
            sb.append('\n');
         }
      }
      return sb.toString();
   }
   @Override
   public String toString() {
      StringBuilder sb = new StringBuilder();
      sb.append("#");
      sb.append(number);
      sb.append(": " + title + "\n");
      for (Change change : changes) {
         sb.append(change);
         sb.append('\n');
      }
      return sb.toString();
   }
   @Override
   public int compareTo(TicketModel o) {
      return o.created.compareTo(created);
   }
   @Override
   public boolean equals(Object o) {
      if (o instanceof TicketModel) {
         return number == ((TicketModel) o).number;
      }
      return super.equals(o);
   }
   @Override
   public int hashCode() {
      return (repository + number).hashCode();
   }
   /**
    * Encapsulates a ticket change
    */
   public static class Change implements Serializable, Comparable<Change> {
      private static final long serialVersionUID = 1L;
      public final Date date;
      public final String author;
      public Comment comment;
      public Map<Field, String> fields;
      public Set<Attachment> attachments;
      public Patchset patchset;
      public Review review;
      private transient String id;
      public Change(String author) {
         this(author, new Date());
      }
      public Change(String author, Date date) {
         this.date = date;
         this.author = author;
      }
      public boolean isStatusChange() {
         return hasField(Field.status);
      }
      public Status getStatus() {
         Status state = Status.fromObject(getField(Field.status), null);
         return state;
      }
      public boolean isMerge() {
         return hasField(Field.status) && hasField(Field.mergeSha);
      }
      public boolean hasPatchset() {
         return patchset != null;
      }
      public boolean hasReview() {
         return review != null;
      }
      public boolean hasComment() {
         return comment != null && !comment.isDeleted();
      }
      public Comment comment(String text) {
         comment = new Comment(text);
         comment.id = TicketModel.getSHA1(date.toString() + author + text);
         try {
            Pattern mentions = Pattern.compile("\\s@([A-Za-z0-9-_]+)");
            Matcher m = mentions.matcher(text);
            while (m.find()) {
               String username = m.group(1);
               plusList(Field.mentions, username);
            }
         } catch (Exception e) {
            // ignore
         }
         return comment;
      }
      public Review review(Patchset patchset, Score score, boolean addReviewer) {
         if (addReviewer) {
            plusList(Field.reviewers, author);
         }
         review = new Review(patchset.number, patchset.rev);
         review.score = score;
         return review;
      }
      public boolean hasAttachments() {
         return !TicketModel.isEmpty(attachments);
      }
      public void addAttachment(Attachment attachment) {
         if (attachments == null) {
            attachments = new LinkedHashSet<Attachment>();
         }
         attachments.add(attachment);
      }
      public Attachment getAttachment(String name) {
         if (attachments != null) {
            for (Attachment attachment : attachments) {
               if (attachment.name.equalsIgnoreCase(name)) {
                  return attachment;
               }
            }
         }
         return null;
      }
      public boolean isParticipantChange() {
         if (hasComment()
               || hasReview()
               || hasPatchset()
               || hasAttachments()) {
            return true;
         }
         if (TicketModel.isEmpty(fields)) {
            return false;
         }
         // identify real ticket field changes
         Map<Field, String> map = new HashMap<Field, String>(fields);
         map.remove(Field.watchers);
         map.remove(Field.voters);
         return !map.isEmpty();
      }
      public boolean hasField(Field field) {
         return !TicketModel.isEmpty(getString(field));
      }
      public boolean hasFieldChanges() {
         return !TicketModel.isEmpty(fields);
      }
      public String getField(Field field) {
         if (fields != null) {
            return fields.get(field);
         }
         return null;
      }
      public void setField(Field field, Object value) {
         if (fields == null) {
            fields = new LinkedHashMap<Field, String>();
         }
         if (value == null) {
            fields.put(field, null);
         } else if (Enum.class.isAssignableFrom(value.getClass())) {
            fields.put(field, ((Enum<?>) value).name());
         } else {
            fields.put(field, value.toString());
         }
      }
      public void remove(Field field) {
         if (fields != null) {
            fields.remove(field);
         }
      }
      public String getString(Field field) {
         String value = getField(field);
         if (value == null) {
            return null;
         }
         return value;
      }
      public void watch(String... username) {
         plusList(Field.watchers, username);
      }
      public void unwatch(String... username) {
         minusList(Field.watchers, username);
      }
      public void vote(String... username) {
         plusList(Field.voters, username);
      }
      public void unvote(String... username) {
         minusList(Field.voters, username);
      }
      public void label(String... label) {
         plusList(Field.labels, label);
      }
      public void unlabel(String... label) {
         minusList(Field.labels, label);
      }
      protected void plusList(Field field, String... items) {
         modList(field, "+", items);
      }
      protected void minusList(Field field, String... items) {
         modList(field, "-", items);
      }
      private void modList(Field field, String prefix, String... items) {
         List<String> list = new ArrayList<String>();
         for (String item : items) {
            list.add(prefix + item);
         }
         setField(field, join(list, ","));
      }
      public String getId() {
         if (id == null) {
            id = getSHA1(Long.toHexString(date.getTime()) + author);
         }
         return id;
      }
      @Override
      public int compareTo(Change c) {
         return date.compareTo(c.date);
      }
      @Override
      public int hashCode() {
         return getId().hashCode();
      }
      @Override
      public boolean equals(Object o) {
         if (o instanceof Change) {
            return getId().equals(((Change) o).getId());
         }
         return false;
      }
      @Override
      public String toString() {
         StringBuilder sb = new StringBuilder();
         sb.append(RelativeDateFormatter.format(date));
         if (hasComment()) {
            sb.append(" commented on by ");
         } else if (hasPatchset()) {
            sb.append(MessageFormat.format(" {0} uploaded by ", patchset));
         } else {
            sb.append(" changed by ");
         }
         sb.append(author).append(" - ");
         if (hasComment()) {
            if (comment.isDeleted()) {
               sb.append("(deleted) ");
            }
            sb.append(comment.text).append(" ");
         }
         if (hasFieldChanges()) {
            for (Map.Entry<Field, String> entry : fields.entrySet()) {
               sb.append("\n  ");
               sb.append(entry.getKey().name());
               sb.append(':');
               sb.append(entry.getValue());
            }
         }
         return sb.toString();
      }
   }
   /**
    * Returns true if the string is null or empty.
    *
    * @param value
    * @return true if string is null or empty
    */
   static boolean isEmpty(String value) {
      return value == null || value.trim().length() == 0;
   }
   /**
    * Returns true if the collection is null or empty
    *
    * @param collection
    * @return
    */
   static boolean isEmpty(Collection<?> collection) {
      return collection == null || collection.size() == 0;
   }
   /**
    * Returns true if the map is null or empty
    *
    * @param map
    * @return
    */
   static boolean isEmpty(Map<?, ?> map) {
      return map == null || map.size() == 0;
   }
   /**
    * Calculates the SHA1 of the string.
    *
    * @param text
    * @return sha1 of the string
    */
   static String getSHA1(String text) {
      try {
         byte[] bytes = text.getBytes("iso-8859-1");
         return getSHA1(bytes);
      } catch (UnsupportedEncodingException u) {
         throw new RuntimeException(u);
      }
   }
   /**
    * Calculates the SHA1 of the byte array.
    *
    * @param bytes
    * @return sha1 of the byte array
    */
   static String getSHA1(byte[] bytes) {
      try {
         MessageDigest md = MessageDigest.getInstance("SHA-1");
         md.update(bytes, 0, bytes.length);
         byte[] digest = md.digest();
         return toHex(digest);
      } catch (NoSuchAlgorithmException t) {
         throw new RuntimeException(t);
      }
   }
   /**
    * Returns the hex representation of the byte array.
    *
    * @param bytes
    * @return byte array as hex string
    */
   static String toHex(byte[] bytes) {
      StringBuilder sb = new StringBuilder(bytes.length * 2);
      for (int i = 0; i < bytes.length; i++) {
         if ((bytes[i] & 0xff) < 0x10) {
            sb.append('0');
         }
         sb.append(Long.toString(bytes[i] & 0xff, 16));
      }
      return sb.toString();
   }
   /**
    * Join the list of strings into a single string with a space separator.
    *
    * @param values
    * @return joined list
    */
   static String join(Collection<String> values) {
      return join(values, " ");
   }
   /**
    * Join the list of strings into a single string with the specified
    * separator.
    *
    * @param values
    * @param separator
    * @return joined list
    */
   static String join(String[]  values, String separator) {
      return join(Arrays.asList(values), separator);
   }
   /**
    * Join the list of strings into a single string with the specified
    * separator.
    *
    * @param values
    * @param separator
    * @return joined list
    */
   static String join(Collection<String> values, String separator) {
      StringBuilder sb = new StringBuilder();
      for (String value : values) {
         sb.append(value).append(separator);
      }
      if (sb.length() > 0) {
         // truncate trailing separator
         sb.setLength(sb.length() - separator.length());
      }
      return sb.toString().trim();
   }
   /**
    * Produce a deep copy of the given object. Serializes the entire object to
    * a byte array in memory. Recommended for relatively small objects.
    */
   @SuppressWarnings("unchecked")
   static <T> T copy(T original) {
      T o = null;
      try {
         ByteArrayOutputStream byteOut = new ByteArrayOutputStream();
         ObjectOutputStream oos = new ObjectOutputStream(byteOut);
         oos.writeObject(original);
         ByteArrayInputStream byteIn = new ByteArrayInputStream(byteOut.toByteArray());
         ObjectInputStream ois = new ObjectInputStream(byteIn);
         try {
            o = (T) ois.readObject();
         } catch (ClassNotFoundException cex) {
            // actually can not happen in this instance
         }
      } catch (IOException iox) {
         // doesn't seem likely to happen as these streams are in memory
         throw new RuntimeException(iox);
      }
      return o;
   }
   public static class Patchset implements Serializable, Comparable<Patchset> {
      private static final long serialVersionUID = 1L;
      public int number;
      public int rev;
      public String tip;
      public String parent;
      public String base;
      public int insertions;
      public int deletions;
      public int commits;
      public int added;
      public PatchsetType type;
      public boolean isFF() {
         return PatchsetType.FastForward == type;
      }
      @Override
      public int hashCode() {
         return toString().hashCode();
      }
      @Override
      public boolean equals(Object o) {
         if (o instanceof Patchset) {
            return hashCode() == o.hashCode();
         }
         return false;
      }
      @Override
      public int compareTo(Patchset p) {
         if (number > p.number) {
            return -1;
         } else if (p.number > number) {
            return 1;
         } else {
            // same patchset, different revision
            if (rev > p.rev) {
               return -1;
            } else if (p.rev > rev) {
               return 1;
            } else {
               // same patchset & revision
               return 0;
            }
         }
      }
      @Override
      public String toString() {
         return "patchset " + number + " revision " + rev;
      }
   }
   public static class Comment implements Serializable {
      private static final long serialVersionUID = 1L;
      public String text;
      public String id;
      public Boolean deleted;
      public CommentSource src;
      public String replyTo;
      Comment(String text) {
         this.text = text;
      }
      public boolean isDeleted() {
         return deleted != null && deleted;
      }
      @Override
      public String toString() {
         return text;
      }
   }
   public static class Attachment implements Serializable {
      private static final long serialVersionUID = 1L;
      public final String name;
      public long size;
      public byte[] content;
      public Boolean deleted;
      public Attachment(String name) {
         this.name = name;
      }
      public boolean isDeleted() {
         return deleted != null && deleted;
      }
      @Override
      public int hashCode() {
         return name.hashCode();
      }
      @Override
      public boolean equals(Object o) {
         if (o instanceof Attachment) {
            return name.equalsIgnoreCase(((Attachment) o).name);
         }
         return false;
      }
      @Override
      public String toString() {
         return name;
      }
   }
   public static class Review implements Serializable {
      private static final long serialVersionUID = 1L;
      public final int patchset;
      public final int rev;
      public Score score;
      public Review(int patchset, int revision) {
         this.patchset = patchset;
         this.rev = revision;
      }
      public boolean isReviewOf(Patchset p) {
         return patchset == p.number && rev == p.rev;
      }
      @Override
      public String toString() {
         return "review of patchset " + patchset + " rev " + rev + ":" + score;
      }
   }
   public static enum Score {
      approved(2), looks_good(1), not_reviewed(0), needs_improvement(-1), vetoed(-2);
      final int value;
      Score(int value) {
         this.value = value;
      }
      public int getValue() {
         return value;
      }
      @Override
      public String toString() {
         return name().toLowerCase().replace('_', ' ');
      }
   }
   public static enum Field {
      title, body, responsible, type, status, milestone, mergeSha, mergeTo,
      topic, labels, watchers, reviewers, voters, mentions;
   }
   public static enum Type {
      Enhancement, Task, Bug, Proposal, Question;
      public static Type defaultType = Task;
      public static Type [] choices() {
         return new Type [] { Enhancement, Task, Bug, Question };
      }
      @Override
      public String toString() {
         return name().toLowerCase().replace('_', ' ');
      }
      public static Type fromObject(Object o, Type defaultType) {
         if (o instanceof Type) {
            // cast and return
            return (Type) o;
         } else if (o instanceof String) {
            // find by name
            for (Type type : values()) {
               String str = o.toString();
               if (type.name().equalsIgnoreCase(str)
                     || type.toString().equalsIgnoreCase(str)) {
                  return type;
               }
            }
         } else if (o instanceof Number) {
            // by ordinal
            int id = ((Number) o).intValue();
            if (id >= 0 && id < values().length) {
               return values()[id];
            }
         }
         return defaultType;
      }
   }
   public static enum Status {
      New, Open, Resolved, Fixed, Merged, Wontfix, Declined, Duplicate, Invalid, On_Hold;
      public static Status [] requestWorkflow = { Open, Resolved, Declined, Duplicate, Invalid, On_Hold };
      public static Status [] bugWorkflow = { Open, Fixed, Wontfix, Duplicate, Invalid, On_Hold };
      public static Status [] proposalWorkflow = { Open, Declined, On_Hold};
      @Override
      public String toString() {
         return name().toLowerCase().replace('_', ' ');
      }
      public static Status fromObject(Object o, Status defaultStatus) {
         if (o instanceof Status) {
            // cast and return
            return (Status) o;
         } else if (o instanceof String) {
            // find by name
            String name = o.toString();
            for (Status state : values()) {
               if (state.name().equalsIgnoreCase(name)
                     || state.toString().equalsIgnoreCase(name)) {
                  return state;
               }
            }
         } else if (o instanceof Number) {
            // by ordinal
            int id = ((Number) o).intValue();
            if (id >= 0 && id < values().length) {
               return values()[id];
            }
         }
         return defaultStatus;
      }
      public boolean isClosed() {
         return ordinal() > Open.ordinal();
      }
   }
   public static enum CommentSource {
      Comment, Email
   }
   public static enum PatchsetType {
      Proposal, FastForward, Rebase, Squash, Rebase_Squash, Amend;
      public boolean isRewrite() {
         return (this != FastForward) && (this != Proposal);
      }
      @Override
      public String toString() {
         return name().toLowerCase().replace('_', '+');
      }
      public static PatchsetType fromObject(Object o) {
         if (o instanceof PatchsetType) {
            // cast and return
            return (PatchsetType) o;
         } else if (o instanceof String) {
            // find by name
            String name = o.toString();
            for (PatchsetType type : values()) {
               if (type.name().equalsIgnoreCase(name)
                     || type.toString().equalsIgnoreCase(name)) {
                  return type;
               }
            }
         } else if (o instanceof Number) {
            // by ordinal
            int id = ((Number) o).intValue();
            if (id >= 0 && id < values().length) {
               return values()[id];
            }
         }
         return null;
      }
   }
}