From 5e3521f8496511db4df45f011ea72f25623ad90f Mon Sep 17 00:00:00 2001
From: James Moger <james.moger@gitblit.com>
Date: Mon, 03 Mar 2014 21:34:32 -0500
Subject: [PATCH] Ticket tracker with patchset contributions

---
 src/main/java/com/gitblit/models/TicketModel.java | 1286 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 1,286 insertions(+), 0 deletions(-)

diff --git a/src/main/java/com/gitblit/models/TicketModel.java b/src/main/java/com/gitblit/models/TicketModel.java
new file mode 100644
index 0000000..1ff55dd
--- /dev/null
+++ b/src/main/java/com/gitblit/models/TicketModel.java
@@ -0,0 +1,1286 @@
+/*
+ * 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;
+		}
+	}
+}

--
Gitblit v1.9.1