From 0f43a54527845b5873f35dc80300d578bfe84bb0 Mon Sep 17 00:00:00 2001
From: James Moger <james.moger@gitblit.com>
Date: Fri, 13 Jan 2012 07:58:12 -0500
Subject: [PATCH] Branch for implementing distributed gb-issues

---
 src/com/gitblit/utils/IssueUtils.java     |  455 ++++++++++++++++++++++++++++
 tests/com/gitblit/tests/IssuesTest.java   |  115 +++++++
 src/com/gitblit/utils/JsonUtils.java      |   34 +
 tests/com/gitblit/tests/GitBlitSuite.java |    6 
 src/com/gitblit/models/IssueModel.java    |  310 +++++++++++++++++++
 src/com/gitblit/utils/JGitUtils.java      |   33 +
 6 files changed, 942 insertions(+), 11 deletions(-)

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

--
Gitblit v1.9.1