Branch for implementing distributed gb-issues
diff --git a/src/com/gitblit/models/IssueModel.java b/src/com/gitblit/models/IssueModel.java
new file mode 100644
index 0000000..3c6d9a0
--- /dev/null
+++ b/src/com/gitblit/models/IssueModel.java
@@ -0,0 +1,310 @@
+/*

+ * Copyright 2012 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.util.ArrayList;

+import java.util.Date;

+import java.util.List;

+

+import com.gitblit.utils.ArrayUtils;

+import com.gitblit.utils.StringUtils;

+

+/**

+ * The Gitblit Issue model, its component classes, and enums.

+ * 

+ * @author James Moger

+ * 

+ */

+public class IssueModel implements Serializable, Comparable<IssueModel> {

+

+	private static final long serialVersionUID = 1L;;

+

+	public String id;

+

+	public Type type;

+

+	public Status status;

+

+	public Priority priority;

+

+	public Date created;

+

+	public String summary;

+

+	public String description;

+

+	public String reporter;

+

+	public String owner;

+

+	public String milestone;

+

+	public List<Change> changes;

+

+	public IssueModel() {

+		created = new Date();

+

+		type = Type.Defect;

+		status = Status.New;

+		priority = Priority.Medium;

+

+		changes = new ArrayList<Change>();

+	}

+

+	public String getStatus() {

+		String s = status.toString();

+		if (!StringUtils.isEmpty(owner))

+			s += " (" + owner + ")";

+		return s;

+	}

+

+	public List<String> getLabels() {

+		List<String> list = new ArrayList<String>();

+		String labels = null;

+		for (Change change : changes) {

+			if (change.hasFieldChanges()) {

+				FieldChange field = change.getField(Field.Labels);

+				if (field != null) {

+					labels = field.value.toString();

+				}

+			}

+		}

+		if (!StringUtils.isEmpty(labels)) {

+			list.addAll(StringUtils.getStringsFromValue(labels, " "));

+		}

+		return list;

+	}

+

+	public boolean hasLabel(String label) {

+		return getLabels().contains(label);

+	}

+

+	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 void addChange(Change change) {

+		if (changes == null) {

+			changes = new ArrayList<Change>();

+		}

+		changes.add(change);

+	}

+

+	@Override

+	public String toString() {

+		return summary;

+	}

+

+	@Override

+	public int compareTo(IssueModel o) {

+		return o.created.compareTo(created);

+	}

+

+	@Override

+	public boolean equals(Object o) {

+		if (o instanceof IssueModel)

+			return id.equals(((IssueModel) o).id);

+		return super.equals(o);

+	}

+

+	@Override

+	public int hashCode() {

+		return id.hashCode();

+	}

+

+	public static class Change implements Serializable {

+

+		private static final long serialVersionUID = 1L;

+

+		public Date created;

+

+		public String author;

+

+		public Comment comment;

+

+		public List<FieldChange> fieldChanges;

+

+		public List<Attachment> attachments;

+

+		public void comment(String text) {

+			comment = new Comment(text);

+		}

+

+		public boolean hasComment() {

+			return comment != null;

+		}

+

+		public boolean hasAttachments() {

+			return !ArrayUtils.isEmpty(attachments);

+		}

+

+		public boolean hasFieldChanges() {

+			return !ArrayUtils.isEmpty(fieldChanges);

+		}

+

+		public FieldChange getField(Field field) {

+			if (fieldChanges != null) {

+				for (FieldChange fieldChange : fieldChanges) {

+					if (fieldChange.field == field) {

+						return fieldChange;

+					}

+				}

+			}

+			return null;

+		}

+

+		public void setField(Field field, Object value) {

+			FieldChange fieldChange = new FieldChange();

+			fieldChange.field = field;

+			fieldChange.value = value;

+			if (fieldChanges == null) {

+				fieldChanges = new ArrayList<FieldChange>();

+			}

+			fieldChanges.add(fieldChange);

+		}

+

+		public String getString(Field field) {

+			FieldChange fieldChange = getField(field);

+			if (fieldChange == null) {

+				return null;

+			}

+			return fieldChange.value.toString();

+		}

+

+		public void addAttachment(Attachment attachment) {

+			if (attachments == null) {

+				attachments = new ArrayList<Attachment>();

+			}

+			attachments.add(attachment);

+		}

+

+		public Attachment getAttachment(String name) {

+			for (Attachment attachment : attachments) {

+				if (attachment.name.equalsIgnoreCase(name)) {

+					return attachment;

+				}

+			}

+			return null;

+		}

+

+		@Override

+		public String toString() {

+			return created.toString() + " by " + author;

+		}

+	}

+

+	public static class Comment implements Serializable {

+

+		private static final long serialVersionUID = 1L;

+

+		public String text;

+		public boolean deleted;

+

+		Comment(String text) {

+			this.text = text;

+		}

+

+		@Override

+		public String toString() {

+			return text;

+		}

+	}

+

+	public static class FieldChange implements Serializable {

+

+		private static final long serialVersionUID = 1L;

+

+		public Field field;

+

+		public Object value;

+

+		@Override

+		public String toString() {

+			return field + ": " + value;

+		}

+	}

+

+	public static class Attachment implements Serializable {

+

+		private static final long serialVersionUID = 1L;

+

+		public String name;

+		public long size;

+		public byte[] content;

+		public boolean deleted;

+

+		public Attachment(String name) {

+			this.name = name;

+		}

+

+		@Override

+		public String toString() {

+			return name;

+		}

+	}

+

+	public static enum Field {

+		Summary, Description, Reporter, Owner, Type, Status, Priority, Milestone, Labels;

+	}

+

+	public static enum Type {

+		Defect, Enhancement, Task, Review, Other;

+	}

+

+	public static enum Priority {

+		Low, Medium, High, Critical;

+	}

+

+	public static enum Status {

+		New, Accepted, Started, Review, Queued, Testing, Done, Fixed, WontFix, Duplicate, Invalid;

+

+		public boolean atLeast(Status status) {

+			return ordinal() >= status.ordinal();

+		}

+

+		public boolean exceeds(Status status) {

+			return ordinal() > status.ordinal();

+		}

+

+		public Status next() {

+			switch (this) {

+			case New:

+				return Started;

+			case Accepted:

+				return Started;

+			case Started:

+				return Testing;

+			case Review:

+				return Testing;

+			case Queued:

+				return Testing;

+			case Testing:

+				return Done;

+			}

+			return Accepted;

+		}

+	}

+}

diff --git a/src/com/gitblit/utils/IssueUtils.java b/src/com/gitblit/utils/IssueUtils.java
new file mode 100644
index 0000000..8217070
--- /dev/null
+++ b/src/com/gitblit/utils/IssueUtils.java
@@ -0,0 +1,455 @@
+/*

+ * Copyright 2012 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.utils;

+

+import java.io.IOException;

+import java.text.MessageFormat;

+import java.util.ArrayList;

+import java.util.Arrays;

+import java.util.Collections;

+import java.util.Date;

+import java.util.HashMap;

+import java.util.List;

+import java.util.Map;

+

+import org.eclipse.jgit.JGitText;

+import org.eclipse.jgit.api.errors.ConcurrentRefUpdateException;

+import org.eclipse.jgit.api.errors.JGitInternalException;

+import org.eclipse.jgit.dircache.DirCache;

+import org.eclipse.jgit.dircache.DirCacheBuilder;

+import org.eclipse.jgit.dircache.DirCacheEntry;

+import org.eclipse.jgit.lib.CommitBuilder;

+import org.eclipse.jgit.lib.Constants;

+import org.eclipse.jgit.lib.FileMode;

+import org.eclipse.jgit.lib.ObjectId;

+import org.eclipse.jgit.lib.ObjectInserter;

+import org.eclipse.jgit.lib.PersonIdent;

+import org.eclipse.jgit.lib.RefUpdate;

+import org.eclipse.jgit.lib.RefUpdate.Result;

+import org.eclipse.jgit.lib.Repository;

+import org.eclipse.jgit.revwalk.RevCommit;

+import org.eclipse.jgit.revwalk.RevTree;

+import org.eclipse.jgit.revwalk.RevWalk;

+import org.eclipse.jgit.treewalk.CanonicalTreeParser;

+import org.eclipse.jgit.treewalk.TreeWalk;

+

+import com.gitblit.models.IssueModel;

+import com.gitblit.models.IssueModel.Attachment;

+import com.gitblit.models.IssueModel.Change;

+import com.gitblit.models.IssueModel.Field;

+import com.gitblit.models.PathModel;

+import com.gitblit.models.RefModel;

+import com.gitblit.utils.JsonUtils.ExcludeField;

+import com.google.gson.Gson;

+

+/**

+ * Utility class for reading Gitblit issues.

+ * 

+ * @author James Moger

+ * 

+ */

+public class IssueUtils {

+

+	public static final String GB_ISSUES = "refs/heads/gb-issues";

+

+	/**

+	 * Returns a RefModel for the gb-issues branch in the repository. If the

+	 * branch can not be found, null is returned.

+	 * 

+	 * @param repository

+	 * @return a refmodel for the gb-issues branch or null

+	 */

+	public static RefModel getIssuesBranch(Repository repository) {

+		return JGitUtils.getBranch(repository, "gb-issues");

+	}

+

+	/**

+	 * Returns all the issues in the repository.

+	 * 

+	 * @param repository

+	 * @param filter

+	 *            optional issue filter to only return matching results

+	 * @return a list of issues

+	 */

+	public static List<IssueModel> getIssues(Repository repository, IssueFilter filter) {

+		List<IssueModel> list = new ArrayList<IssueModel>();

+		RefModel issuesBranch = getIssuesBranch(repository);

+		if (issuesBranch == null) {

+			return list;

+		}

+		List<PathModel> paths = JGitUtils

+				.getDocuments(repository, Arrays.asList("json"), GB_ISSUES);

+		RevTree tree = JGitUtils.getCommit(repository, GB_ISSUES).getTree();

+		for (PathModel path : paths) {

+			String json = JGitUtils.getStringContent(repository, tree, path.path);

+			IssueModel issue = JsonUtils.fromJsonString(json, IssueModel.class);

+			if (filter == null) {

+				list.add(issue);

+			} else {

+				if (filter.accept(issue)) {

+					list.add(issue);

+				}

+			}

+		}

+		Collections.sort(list);

+		return list;

+	}

+

+	/**

+	 * Retrieves the specified issue from the repository with complete changes

+	 * history.

+	 * 

+	 * @param repository

+	 * @param issueId

+	 * @return an issue, if it exists, otherwise null

+	 */

+	public static IssueModel getIssue(Repository repository, String issueId) {

+		RefModel issuesBranch = getIssuesBranch(repository);

+		if (issuesBranch == null) {

+			return null;

+		}

+

+		if (StringUtils.isEmpty(issueId)) {

+			return null;

+		}

+

+		// deserialize the issue model object

+		IssueModel issue = null;

+		String issuePath = getIssuePath(issueId);

+		RevTree tree = JGitUtils.getCommit(repository, GB_ISSUES).getTree();

+		String json = JGitUtils.getStringContent(repository, tree, issuePath + "/issue.json");

+		issue = JsonUtils.fromJsonString(json, IssueModel.class);

+		return issue;

+	}

+

+	/**

+	 * Retrieves the specified attachment from an issue.

+	 * 

+	 * @param repository

+	 * @param issueId

+	 * @param filename

+	 * @return an attachment, if found, null otherwise

+	 */

+	public static Attachment getIssueAttachment(Repository repository, String issueId,

+			String filename) {

+		RefModel issuesBranch = getIssuesBranch(repository);

+		if (issuesBranch == null) {

+			return null;

+		}

+

+		if (StringUtils.isEmpty(issueId)) {

+			return null;

+		}

+

+		// deserialize the issue model so that we have the attachment metadata

+		String issuePath = getIssuePath(issueId);

+		RevTree tree = JGitUtils.getCommit(repository, GB_ISSUES).getTree();

+		String json = JGitUtils.getStringContent(repository, tree, issuePath + "/issue.json");

+		IssueModel issue = JsonUtils.fromJsonString(json, IssueModel.class);

+		Attachment attachment = issue.getAttachment(filename);

+

+		// attachment not found

+		if (attachment == null) {

+			return null;

+		}

+

+		// retrieve the attachment content

+		byte[] content = JGitUtils.getByteContent(repository, tree, issuePath + "/" + filename);

+		attachment.content = content;

+		attachment.size = content.length;

+		return attachment;

+	}

+

+	/**

+	 * Stores an issue in the gb-issues branch of the repository. The branch is

+	 * automatically created if it does not already exist.

+	 * 

+	 * @param repository

+	 * @param change

+	 * @return true if successful

+	 */

+	public static IssueModel createIssue(Repository repository, Change change) {

+		RefModel issuesBranch = getIssuesBranch(repository);

+		if (issuesBranch == null) {

+			JGitUtils.createOrphanBranch(repository, "gb-issues", null);

+		}

+		change.created = new Date();

+

+		IssueModel issue = new IssueModel();

+		issue.created = change.created;

+		issue.summary = change.getString(Field.Summary);

+		issue.description = change.getString(Field.Description);

+		issue.reporter = change.getString(Field.Reporter);

+

+		if (StringUtils.isEmpty(issue.summary)) {

+			throw new RuntimeException("Must specify an issue summary!");

+		}

+		if (StringUtils.isEmpty(change.getString(Field.Description))) {

+			throw new RuntimeException("Must specify an issue description!");

+		}

+		if (StringUtils.isEmpty(change.getString(Field.Reporter))) {

+			throw new RuntimeException("Must specify an issue reporter!");

+		}

+

+		issue.id = StringUtils.getSHA1(issue.created.toString() + issue.reporter + issue.summary

+				+ issue.description);

+

+		String message = createChangelog('+', issue.id, change);

+		boolean success = commit(repository, issue, change, message);

+		if (success) {

+			return issue;

+		}

+		return null;

+	}

+

+	/**

+	 * Updates an issue in the gb-issues branch of the repository.

+	 * 

+	 * @param repository

+	 * @param issue

+	 * @param change

+	 * @return true if successful

+	 */

+	public static boolean updateIssue(Repository repository, String issueId, Change change) {

+		boolean success = false;

+		RefModel issuesBranch = getIssuesBranch(repository);

+

+		if (issuesBranch == null) {

+			throw new RuntimeException("gb-issues branch does not exist!");

+		}

+

+		if (change == null) {

+			throw new RuntimeException("change can not be null!");

+		}

+

+		if (StringUtils.isEmpty(change.author)) {

+			throw new RuntimeException("must specify change.author!");

+		}

+

+		IssueModel issue = getIssue(repository, issueId);

+		change.created = new Date();

+

+		String message = createChangelog('=', issueId, change);

+		success = commit(repository, issue, change, message);

+		return success;

+	}

+

+	private static String createChangelog(char type, String issueId, Change change) {

+		return type + " " + issueId + "\n\n" + toJson(change);

+	}

+

+	/**

+	 * 

+	 * @param repository

+	 * @param issue

+	 * @param change

+	 * @param changelog

+	 * @return

+	 */

+	private static boolean commit(Repository repository, IssueModel issue, Change change,

+			String changelog) {

+		boolean success = false;

+		String issuePath = getIssuePath(issue.id);

+		try {

+			issue.addChange(change);

+

+			// serialize the issue as json

+			String json = toJson(issue);

+

+			// cache the issue "files" in a map

+			Map<String, CommitFile> files = new HashMap<String, CommitFile>();

+			CommitFile issueFile = new CommitFile(issuePath + "/issue.json", change.created);

+			issueFile.content = json.getBytes(Constants.CHARACTER_ENCODING);

+			files.put(issueFile.path, issueFile);

+

+			if (change.hasAttachments()) {

+				for (Attachment attachment : change.attachments) {

+					if (!ArrayUtils.isEmpty(attachment.content)) {

+						CommitFile file = new CommitFile(issuePath + "/" + attachment.name,

+								change.created);

+						file.content = attachment.content;

+						files.put(file.path, file);

+					}

+				}

+			}

+

+			ObjectId headId = repository.resolve(GB_ISSUES + "^{commit}");

+

+			ObjectInserter odi = repository.newObjectInserter();

+			try {

+				// Create the in-memory index of the new/updated issue.

+				DirCache index = createIndex(repository, headId, files);

+				ObjectId indexTreeId = index.writeTree(odi);

+

+				// Create a commit object

+				PersonIdent author = new PersonIdent(issue.reporter, issue.reporter + "@gitblit");

+				CommitBuilder commit = new CommitBuilder();

+				commit.setAuthor(author);

+				commit.setCommitter(author);

+				commit.setEncoding(Constants.CHARACTER_ENCODING);

+				commit.setMessage(changelog);

+				commit.setParentId(headId);

+				commit.setTreeId(indexTreeId);

+

+				// Insert the commit into the repository

+				ObjectId commitId = odi.insert(commit);

+				odi.flush();

+

+				RevWalk revWalk = new RevWalk(repository);

+				try {

+					RevCommit revCommit = revWalk.parseCommit(commitId);

+					RefUpdate ru = repository.updateRef(GB_ISSUES);

+					ru.setNewObjectId(commitId);

+					ru.setExpectedOldObjectId(headId);

+					ru.setRefLogMessage("commit: " + revCommit.getShortMessage(), false);

+					Result rc = ru.forceUpdate();

+					switch (rc) {

+					case NEW:

+					case FORCED:

+					case FAST_FORWARD:

+						success = true;

+						break;

+					case REJECTED:

+					case LOCK_FAILURE:

+						throw new ConcurrentRefUpdateException(JGitText.get().couldNotLockHEAD,

+								ru.getRef(), rc);

+					default:

+						throw new JGitInternalException(MessageFormat.format(

+								JGitText.get().updatingRefFailed, GB_ISSUES, commitId.toString(),

+								rc));

+					}

+				} finally {

+					revWalk.release();

+				}

+			} finally {

+				odi.release();

+			}

+		} catch (Throwable t) {

+			t.printStackTrace();

+		}

+		return success;

+	}

+

+	private static String toJson(Object o) {

+		try {

+			// exclude the attachment content field from json serialization

+			Gson gson = JsonUtils.gson(new ExcludeField(

+					"com.gitblit.models.IssueModel$Attachment.content"));

+			String json = gson.toJson(o);

+			return json;

+		} catch (Throwable t) {

+			throw new RuntimeException(t);

+		}

+	}

+

+	/**

+	 * Returns the issue path. This follows the same scheme as Git's object

+	 * store path where the first two characters of the hash id are the root

+	 * folder with the remaining characters as a subfolder within that folder.

+	 * 

+	 * @param issueId

+	 * @return the root path of the issue content on the gb-issues branch

+	 */

+	private static String getIssuePath(String issueId) {

+		return issueId.substring(0, 2) + "/" + issueId.substring(2);

+	}

+

+	/**

+	 * Creates an in-memory index of the issue change.

+	 * 

+	 * @param repo

+	 * @param headId

+	 * @param files

+	 * @param time

+	 * @return an in-memory index

+	 * @throws IOException

+	 */

+	private static DirCache createIndex(Repository repo, ObjectId headId,

+			Map<String, CommitFile> files) throws IOException {

+

+		DirCache inCoreIndex = DirCache.newInCore();

+		DirCacheBuilder dcBuilder = inCoreIndex.builder();

+		ObjectInserter inserter = repo.newObjectInserter();

+

+		try {

+			// Add the issue files to the temporary index

+			for (CommitFile file : files.values()) {

+				// create an index entry for the file

+				final DirCacheEntry dcEntry = new DirCacheEntry(file.path);

+				dcEntry.setLength(file.content.length);

+				dcEntry.setLastModified(file.time);

+				dcEntry.setFileMode(FileMode.REGULAR_FILE);

+

+				// insert object

+				dcEntry.setObjectId(inserter.insert(Constants.OBJ_BLOB, file.content));

+

+				// add to temporary in-core index

+				dcBuilder.add(dcEntry);

+			}

+

+			// Traverse HEAD to add all other paths

+			TreeWalk treeWalk = new TreeWalk(repo);

+			int hIdx = -1;

+			if (headId != null)

+				hIdx = treeWalk.addTree(new RevWalk(repo).parseTree(headId));

+			treeWalk.setRecursive(true);

+

+			while (treeWalk.next()) {

+				String path = treeWalk.getPathString();

+				CanonicalTreeParser hTree = null;

+				if (hIdx != -1)

+					hTree = treeWalk.getTree(hIdx, CanonicalTreeParser.class);

+				if (!files.containsKey(path)) {

+					// add entries from HEAD for all other paths

+					if (hTree != null) {

+						// create a new DirCacheEntry with data retrieved from

+						// HEAD

+						final DirCacheEntry dcEntry = new DirCacheEntry(path);

+						dcEntry.setObjectId(hTree.getEntryObjectId());

+						dcEntry.setFileMode(hTree.getEntryFileMode());

+

+						// add to temporary in-core index

+						dcBuilder.add(dcEntry);

+					}

+				}

+			}

+

+			// release the treewalk

+			treeWalk.release();

+

+			// finish temporary in-core index used for this commit

+			dcBuilder.finish();

+		} finally {

+			inserter.release();

+		}

+		return inCoreIndex;

+	}

+

+	private static class CommitFile {

+		String path;

+		long time;

+		byte[] content;

+

+		CommitFile(String path, Date date) {

+			this.path = path;

+			this.time = date.getTime();

+		}

+	}

+

+	public static interface IssueFilter {

+		public abstract boolean accept(IssueModel issue);

+	}

+}

diff --git a/src/com/gitblit/utils/JGitUtils.java b/src/com/gitblit/utils/JGitUtils.java
index a540c2a..5d6011a 100644
--- a/src/com/gitblit/utils/JGitUtils.java
+++ b/src/com/gitblit/utils/JGitUtils.java
@@ -24,7 +24,6 @@
 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;

@@ -748,25 +747,40 @@
 	}

 

 	/**

-	 * Returns the list of files in the repository that match one of the

-	 * specified extensions. This is a CASE-SENSITIVE search. If the repository

-	 * does not exist or is empty, an empty list is returned.

+	 * Returns the list of files in the repository on the default branch that

+	 * match one of the specified extensions. This is a CASE-SENSITIVE search.

+	 * If the repository does not exist or is empty, an empty list is returned.

 	 * 

 	 * @param repository

 	 * @param extensions

 	 * @return list of files in repository with a matching extension

 	 */

 	public static List<PathModel> getDocuments(Repository repository, List<String> extensions) {

+		return getDocuments(repository, extensions, null);

+	}

+

+	/**

+	 * Returns the list of files in the repository in the specified commit that

+	 * match one of the specified extensions. This is a CASE-SENSITIVE search.

+	 * If the repository does not exist or is empty, an empty list is returned.

+	 * 

+	 * @param repository

+	 * @param extensions

+	 * @param objectId

+	 * @return list of files in repository with a matching extension

+	 */

+	public static List<PathModel> getDocuments(Repository repository, List<String> extensions,

+			String objectId) {

 		List<PathModel> list = new ArrayList<PathModel>();

 		if (!hasCommits(repository)) {

 			return list;

 		}

-		RevCommit commit = getCommit(repository, null);

+		RevCommit commit = getCommit(repository, objectId);

 		final TreeWalk tw = new TreeWalk(repository);

 		try {

 			tw.addTree(commit.getTree());

 			if (extensions != null && extensions.size() > 0) {

-				Collection<TreeFilter> suffixFilters = new ArrayList<TreeFilter>();

+				List<TreeFilter> suffixFilters = new ArrayList<TreeFilter>();

 				for (String extension : extensions) {

 					if (extension.charAt(0) == '.') {

 						suffixFilters.add(PathSuffixFilter.create("\\" + extension));

@@ -775,7 +789,12 @@
 						suffixFilters.add(PathSuffixFilter.create("\\." + extension));

 					}

 				}

-				TreeFilter filter = OrTreeFilter.create(suffixFilters);

+				TreeFilter filter;

+				if (suffixFilters.size() == 1) {

+					filter = suffixFilters.get(0);

+				} else {

+					filter = OrTreeFilter.create(suffixFilters);

+				}

 				tw.setFilter(filter);

 				tw.setRecursive(true);

 			}

diff --git a/src/com/gitblit/utils/JsonUtils.java b/src/com/gitblit/utils/JsonUtils.java
index da9c99d..aea46bb 100644
--- a/src/com/gitblit/utils/JsonUtils.java
+++ b/src/com/gitblit/utils/JsonUtils.java
@@ -38,6 +38,8 @@
 import com.gitblit.GitBlitException.UnknownRequestException;

 import com.gitblit.models.RepositoryModel;

 import com.gitblit.models.UserModel;

+import com.google.gson.ExclusionStrategy;

+import com.google.gson.FieldAttributes;

 import com.google.gson.Gson;

 import com.google.gson.GsonBuilder;

 import com.google.gson.JsonDeserializationContext;

@@ -108,7 +110,7 @@
 			UnauthorizedException {

 		return retrieveJson(url, type, null, null);

 	}

-	

+

 	/**

 	 * Reads a gson object from the specified url.

 	 * 

@@ -169,10 +171,11 @@
 	 */

 	public static String retrieveJsonString(String url, String username, char[] password)

 			throws IOException {

-		try {			

+		try {

 			URLConnection conn = ConnectionUtils.openReadConnection(url, username, password);

 			InputStream is = conn.getInputStream();

-			BufferedReader reader = new BufferedReader(new InputStreamReader(is, ConnectionUtils.CHARSET));

+			BufferedReader reader = new BufferedReader(new InputStreamReader(is,

+					ConnectionUtils.CHARSET));

 			StringBuilder json = new StringBuilder();

 			char[] buffer = new char[4096];

 			int len = 0;

@@ -260,10 +263,13 @@
 

 	// build custom gson instance with GMT date serializer/deserializer

 	// http://code.google.com/p/google-gson/issues/detail?id=281

-	private static Gson gson() {

+	public static Gson gson(ExclusionStrategy... strategies) {

 		GsonBuilder builder = new GsonBuilder();

 		builder.registerTypeAdapter(Date.class, new GmtDateTypeAdapter());

 		builder.setPrettyPrinting();

+		if (!ArrayUtils.isEmpty(strategies)) {

+			builder.setExclusionStrategies(strategies);

+		}

 		return builder.create();

 	}

 

@@ -296,4 +302,24 @@
 			}

 		}

 	}

+

+	public static class ExcludeField implements ExclusionStrategy {

+

+		private Class<?> c;

+		private String fieldName;

+

+		public ExcludeField(String fqfn) throws SecurityException, NoSuchFieldException,

+				ClassNotFoundException {

+			this.c = Class.forName(fqfn.substring(0, fqfn.lastIndexOf(".")));

+			this.fieldName = fqfn.substring(fqfn.lastIndexOf(".") + 1);

+		}

+

+		public boolean shouldSkipClass(Class<?> arg0) {

+			return false;

+		}

+

+		public boolean shouldSkipField(FieldAttributes f) {

+			return (f.getDeclaringClass() == c && f.getName().equals(fieldName));

+		}

+	}

 }

diff --git a/tests/com/gitblit/tests/GitBlitSuite.java b/tests/com/gitblit/tests/GitBlitSuite.java
index 71947e1..747ce1f 100644
--- a/tests/com/gitblit/tests/GitBlitSuite.java
+++ b/tests/com/gitblit/tests/GitBlitSuite.java
@@ -90,6 +90,10 @@
 		return new FileRepository(new File(REPOSITORIES, "test/theoretical-physics.git"));

 	}

 

+	public static Repository getIssuesTestRepository() throws Exception {

+		return new FileRepository(new File(REPOSITORIES, "gb-issues.git"));

+	}

+

 	public static boolean startGitblit() throws Exception {

 		if (started.get()) {

 			// already started

@@ -134,6 +138,8 @@
 			cloneOrFetch("test/ambition.git", "https://github.com/defunkt/ambition.git");

 			cloneOrFetch("test/theoretical-physics.git", "https://github.com/certik/theoretical-physics.git");

 			

+			JGitUtils.createRepository(REPOSITORIES, "gb-issues.git").close();

+

 			enableTickets("ticgit.git");

 			enableDocs("ticgit.git");

 			showRemoteBranches("ticgit.git");

diff --git a/tests/com/gitblit/tests/IssuesTest.java b/tests/com/gitblit/tests/IssuesTest.java
new file mode 100644
index 0000000..1522ec6
--- /dev/null
+++ b/tests/com/gitblit/tests/IssuesTest.java
@@ -0,0 +1,115 @@
+/*

+ * Copyright 2012 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.tests;

+

+import static org.junit.Assert.assertEquals;

+import static org.junit.Assert.assertNotNull;

+import static org.junit.Assert.assertTrue;

+

+import java.util.List;

+

+import org.bouncycastle.util.Arrays;

+import org.eclipse.jgit.lib.Repository;

+import org.junit.Test;

+

+import com.gitblit.models.IssueModel;

+import com.gitblit.models.IssueModel.Attachment;

+import com.gitblit.models.IssueModel.Change;

+import com.gitblit.models.IssueModel.Field;

+import com.gitblit.models.IssueModel.Priority;

+import com.gitblit.utils.IssueUtils;

+import com.gitblit.utils.IssueUtils.IssueFilter;

+

+public class IssuesTest {

+

+	@Test

+	public void testInsertion() throws Exception {

+		Repository repository = GitBlitSuite.getIssuesTestRepository();

+		// create and insert the issue

+		Change c1 = newChange("Test issue " + Long.toHexString(System.currentTimeMillis()));

+		IssueModel issue = IssueUtils.createIssue(repository, c1);

+		assertNotNull(issue.id);

+

+		// retrieve issue and compare

+		IssueModel constructed = IssueUtils.getIssue(repository, issue.id);

+		compare(issue, constructed);

+

+		// add a note and update

+		Change c2 = new Change();

+		c2.author = "dave";

+		c2.comment("yeah, this is working");		

+		assertTrue(IssueUtils.updateIssue(repository, issue.id, c2));

+

+		// retrieve issue again

+		constructed = IssueUtils.getIssue(repository, issue.id);

+

+		assertEquals(2, constructed.changes.size());

+

+		Attachment a = IssueUtils.getIssueAttachment(repository, issue.id, "test.txt");

+		repository.close();

+

+		assertEquals(10, a.content.length);

+		assertTrue(Arrays.areEqual(new byte[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }, a.content));

+	}

+

+	@Test

+	public void testQuery() throws Exception {

+		Repository repository = GitBlitSuite.getIssuesTestRepository();

+		List<IssueModel> list = IssueUtils.getIssues(repository, null);

+		List<IssueModel> list2 = IssueUtils.getIssues(repository, new IssueFilter() {

+			boolean hasFirst = false;

+			@Override

+			public boolean accept(IssueModel issue) {

+				if (!hasFirst) {

+					hasFirst = true;

+					return true;

+				}

+				return false;

+			}

+		});

+		repository.close();

+		assertTrue(list.size() > 0);

+		assertEquals(1, list2.size());

+	}

+

+	private Change newChange(String summary) {

+		Change change = new Change();

+		change.setField(Field.Reporter, "james");

+		change.setField(Field.Owner, "dave");

+		change.setField(Field.Summary, summary);

+		change.setField(Field.Description, "this is my description");

+		change.setField(Field.Priority, Priority.High);

+		change.setField(Field.Labels, "helpdesk");

+		change.comment("my comment");

+		

+		Attachment attachment = new Attachment("test.txt");		

+		attachment.content = new byte[] { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };

+		change.addAttachment(attachment);		

+		

+		return change;

+	}

+

+	private void compare(IssueModel issue, IssueModel constructed) {

+		assertEquals(issue.id, constructed.id);

+		assertEquals(issue.reporter, constructed.reporter);

+		assertEquals(issue.owner, constructed.owner);

+		assertEquals(issue.created.getTime() / 1000, constructed.created.getTime() / 1000);

+		assertEquals(issue.summary, constructed.summary);

+		assertEquals(issue.description, constructed.description);

+

+		assertTrue(issue.hasLabel("helpdesk"));

+	}

+}
\ No newline at end of file