James Moger
2012-01-13 0f43a54527845b5873f35dc80300d578bfe84bb0
Branch for implementing distributed gb-issues
3 files added
3 files modified
953 ■■■■■ changed files
src/com/gitblit/models/IssueModel.java 310 ●●●●● patch | view | raw | blame | history
src/com/gitblit/utils/IssueUtils.java 455 ●●●●● patch | view | raw | blame | history
src/com/gitblit/utils/JGitUtils.java 33 ●●●● patch | view | raw | blame | history
src/com/gitblit/utils/JsonUtils.java 34 ●●●● patch | view | raw | blame | history
tests/com/gitblit/tests/GitBlitSuite.java 6 ●●●●● patch | view | raw | blame | history
tests/com/gitblit/tests/IssuesTest.java 115 ●●●●● patch | view | raw | blame | history
src/com/gitblit/models/IssueModel.java
New file
@@ -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;
        }
    }
}
src/com/gitblit/utils/IssueUtils.java
New file
@@ -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);
    }
}
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);
            }
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));
        }
    }
}
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");
tests/com/gitblit/tests/IssuesTest.java
New file
@@ -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"));
    }
}