James Moger
2012-02-19 bd01eebfa57b4012bc7a7abc1aaaa1b69278b9de
Merged issues/lucene branch
6 files added
5 files modified
2493 ■■■■■ changed files
.classpath 1 ●●●● patch | view | raw | blame | history
src/com/gitblit/build/Build.java 6 ●●●●● patch | view | raw | blame | history
src/com/gitblit/models/IssueModel.java 532 ●●●●● patch | view | raw | blame | history
src/com/gitblit/models/SearchResult.java 46 ●●●●● patch | view | raw | blame | history
src/com/gitblit/utils/IssueUtils.java 823 ●●●●● patch | view | raw | blame | history
src/com/gitblit/utils/JGitUtils.java 35 ●●●● patch | view | raw | blame | history
src/com/gitblit/utils/JsonUtils.java 33 ●●●●● patch | view | raw | blame | history
src/com/gitblit/utils/LuceneUtils.java 635 ●●●●● patch | view | raw | blame | history
tests/com/gitblit/tests/GitBlitSuite.java 8 ●●●● patch | view | raw | blame | history
tests/com/gitblit/tests/IssuesTest.java 256 ●●●●● patch | view | raw | blame | history
tests/com/gitblit/tests/LuceneUtilsTest.java 118 ●●●●● patch | view | raw | blame | history
.classpath
@@ -27,5 +27,6 @@
    <classpathentry kind="lib" path="ext/org.eclipse.jgit.http.server-1.2.0.201112221803-r.jar" sourcepath="ext/org.eclipse.jgit.http.server-1.2.0.201112221803-r-sources.jar"/>
    <classpathentry kind="lib" path="ext/groovy-all-1.8.5.jar" sourcepath="ext/groovy-all-1.8.5-sources.jar"/>
    <classpathentry kind="lib" path="ext/jetty-ajp-7.4.3.v20110701.jar" sourcepath="ext/jetty-ajp-7.4.3.v20110701-sources.jar"/>
    <classpathentry kind="lib" path="ext/lucene-core-3.5.0.jar" sourcepath="ext/lucene-core-3.5.0-sources.jar"/>
    <classpathentry kind="output" path="bin"/>
</classpath>
src/com/gitblit/build/Build.java
@@ -91,6 +91,7 @@
        downloadFromApache(MavenObject.GSON, BuildType.RUNTIME);
        downloadFromApache(MavenObject.MAIL, BuildType.RUNTIME);
        downloadFromApache(MavenObject.GROOVY, BuildType.RUNTIME);
        downloadFromApache(MavenObject.LUCENE, BuildType.RUNTIME);
        downloadFromEclipse(MavenObject.JGIT, BuildType.RUNTIME);
        downloadFromEclipse(MavenObject.JGIT_HTTP, BuildType.RUNTIME);
@@ -118,6 +119,7 @@
        downloadFromApache(MavenObject.GSON, BuildType.COMPILETIME);
        downloadFromApache(MavenObject.MAIL, BuildType.COMPILETIME);
        downloadFromApache(MavenObject.GROOVY, BuildType.COMPILETIME);
        downloadFromApache(MavenObject.LUCENE, BuildType.COMPILETIME);
        
        downloadFromEclipse(MavenObject.JGIT, BuildType.COMPILETIME);
        downloadFromEclipse(MavenObject.JGIT_HTTP, BuildType.COMPILETIME);
@@ -507,6 +509,10 @@
                "1.8.5", 6143000, 2290000, 4608000, "3be3914c49ca7d8e8afb29a7772a74c30a1f1b28",
                "1435cc8c90e3a91e5fee7bb53e83aad96e93aeb7", "5a214b52286523f9e2a4b5fed526506c763fa6f1");
        public static final MavenObject LUCENE = new MavenObject("lucene", "org/apache/lucene", "lucene-core",
                "3.5.0", 1470000, 1347000, 3608000, "90ff0731fafb05c01fee4f2247140d56e9c30a3b",
                "0757113199f9c8c18c678c96d61c2c4160b9baa6", "19f8e80e5e7f6ec88a41d4f63495994692e31bf1");
        public final String name;
        public final String group;
        public final String artifact;
src/com/gitblit/models/IssueModel.java
New file
@@ -0,0 +1,532 @@
/*
 * 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.LinkedHashSet;
import java.util.List;
import java.util.Set;
import com.gitblit.utils.ArrayUtils;
import com.gitblit.utils.StringUtils;
import com.gitblit.utils.TimeUtils;
/**
 * 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() {
        // the first applied change set the date appropriately
        created = new Date(0);
        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 boolean hasLabel(String label) {
        return getLabels().contains(label);
    }
    public List<String> getLabels() {
        List<String> list = new ArrayList<String>();
        String labels = null;
        for (Change change : changes) {
            if (change.hasField(Field.Labels)) {
                labels = change.getString(Field.Labels);
            }
        }
        if (!StringUtils.isEmpty(labels)) {
            list.addAll(StringUtils.getStringsFromValue(labels, " "));
        }
        return list;
    }
    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 List<Attachment> getAttachments() {
        List<Attachment> list = new ArrayList<Attachment>();
        for (Change change : changes) {
            if (change.hasAttachments()) {
                list.addAll(change.attachments);
            }
        }
        return list;
    }
    public void applyChange(Change change) {
        if (changes.size() == 0) {
            // first change created the issue
            created = change.created;
        }
        changes.add(change);
        if (change.hasFieldChanges()) {
            for (FieldChange fieldChange : change.fieldChanges) {
                switch (fieldChange.field) {
                case Id:
                    id = fieldChange.value.toString();
                    break;
                case Type:
                    type = IssueModel.Type.fromObject(fieldChange.value);
                    break;
                case Status:
                    status = IssueModel.Status.fromObject(fieldChange.value);
                    break;
                case Priority:
                    priority = IssueModel.Priority.fromObject(fieldChange.value);
                    break;
                case Summary:
                    summary = fieldChange.value.toString();
                    break;
                case Description:
                    description = fieldChange.value.toString();
                    break;
                case Reporter:
                    reporter = fieldChange.value.toString();
                    break;
                case Owner:
                    owner = fieldChange.value.toString();
                    break;
                case Milestone:
                    milestone = fieldChange.value.toString();
                    break;
                }
            }
        }
    }
    @Override
    public String toString() {
        StringBuilder sb = new StringBuilder();
        sb.append("issue ");
        sb.append(id.substring(0, 8));
        sb.append(" (" + summary + ")\n");
        for (Change change : changes) {
            sb.append(change);
            sb.append('\n');
        }
        return sb.toString();
    }
    @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, Comparable<Change> {
        private static final long serialVersionUID = 1L;
        public final Date created;
        public final String author;
        public String id;
        public char code;
        public Comment comment;
        public Set<FieldChange> fieldChanges;
        public Set<Attachment> attachments;
        public Change(String author) {
            this.created = new Date((System.currentTimeMillis() / 1000) * 1000);
            this.author = author;
            this.id = StringUtils.getSHA1(created.toString() + author);
        }
        public boolean hasComment() {
            return comment != null && !comment.deleted;
        }
        public void comment(String text) {
            comment = new Comment(text);
            comment.id = StringUtils.getSHA1(created.toString() + author + text);
        }
        public boolean hasAttachments() {
            return !ArrayUtils.isEmpty(attachments);
        }
        public void addAttachment(Attachment attachment) {
            if (attachments == null) {
                attachments = new LinkedHashSet<Attachment>();
            }
            attachments.add(attachment);
        }
        public Attachment getAttachment(String name) {
            for (Attachment attachment : attachments) {
                if (attachment.name.equalsIgnoreCase(name)) {
                    return attachment;
                }
            }
            return null;
        }
        public boolean hasField(Field field) {
            return !StringUtils.isEmpty(getString(field));
        }
        public boolean hasFieldChanges() {
            return !ArrayUtils.isEmpty(fieldChanges);
        }
        public Object getField(Field field) {
            if (fieldChanges != null) {
                for (FieldChange fieldChange : fieldChanges) {
                    if (fieldChange.field == field) {
                        return fieldChange.value;
                    }
                }
            }
            return null;
        }
        public void setField(Field field, Object value) {
            FieldChange fieldChange = new FieldChange(field, value);
            if (fieldChanges == null) {
                fieldChanges = new LinkedHashSet<FieldChange>();
            }
            fieldChanges.add(fieldChange);
        }
        public String getString(Field field) {
            Object value = getField(field);
            if (value == null) {
                return null;
            }
            return value.toString();
        }
        @Override
        public int compareTo(Change c) {
            return created.compareTo(c.created);
        }
        @Override
        public int hashCode() {
            return id.hashCode();
        }
        @Override
        public boolean equals(Object o) {
            if (o instanceof Change) {
                return id.equals(((Change) o).id);
            }
            return false;
        }
        @Override
        public String toString() {
            StringBuilder sb = new StringBuilder();
            sb.append(TimeUtils.timeAgo(created));
            switch (code) {
            case '+':
                sb.append(" created by ");
                break;
            default:
                if (hasComment()) {
                    sb.append(" commented on by ");
                } else {
                    sb.append(" changed by ");
                }
            }
            sb.append(author).append(" - ");
            if (hasComment()) {
                if (comment.deleted) {
                    sb.append("(deleted) ");
                }
                sb.append(comment.text).append(" ");
            }
            if (hasFieldChanges()) {
                switch (code) {
                case '+':
                    break;
                default:
                    for (FieldChange fieldChange : fieldChanges) {
                        sb.append("\n  ");
                        sb.append(fieldChange);
                    }
                    break;
                }
            }
            return sb.toString();
        }
    }
    public static class Comment implements Serializable {
        private static final long serialVersionUID = 1L;
        public String text;
        public String id;
        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 final Field field;
        public final Object value;
        FieldChange(Field field, Object value) {
            this.field = field;
            this.value = value;
        }
        @Override
        public int hashCode() {
            return field.hashCode();
        }
        @Override
        public boolean equals(Object o) {
            if (o instanceof FieldChange) {
                return field.equals(((FieldChange) o).field);
            }
            return false;
        }
        @Override
        public String toString() {
            return field + ": " + value;
        }
    }
    public static class Attachment implements Serializable {
        private static final long serialVersionUID = 1L;
        public final String name;
        public String id;
        public long size;
        public byte[] content;
        public boolean deleted;
        public Attachment(String name) {
            this.name = name;
        }
        @Override
        public int hashCode() {
            return name.hashCode();
        }
        @Override
        public boolean equals(Object o) {
            if (o instanceof Attachment) {
                return name.equalsIgnoreCase(((Attachment) o).name);
            }
            return false;
        }
        @Override
        public String toString() {
            return name;
        }
    }
    public static enum Field {
        Id, Summary, Description, Reporter, Owner, Type, Status, Priority, Milestone, Component, Labels;
    }
    public static enum Type {
        Defect, Enhancement, Task, Review, Other;
        public static Type fromObject(Object o) {
            if (o instanceof Type) {
                // cast and return
                return (Type) o;
            } else if (o instanceof String) {
                // find by name
                for (Type type : values()) {
                    String str = o.toString();
                    if (type.toString().equalsIgnoreCase(str)) {
                        return type;
                    }
                }
            } else if (o instanceof Number) {
                // by ordinal
                int id = ((Number) o).intValue();
                if (id >= 0 && id < values().length) {
                    return values()[id];
                }
            }
            return null;
        }
    }
    public static enum Priority {
        Low, Medium, High, Critical;
        public static Priority fromObject(Object o) {
            if (o instanceof Priority) {
                // cast and return
                return (Priority) o;
            } else if (o instanceof String) {
                // find by name
                for (Priority priority : values()) {
                    String str = o.toString();
                    if (priority.toString().equalsIgnoreCase(str)) {
                        return priority;
                    }
                }
            } else if (o instanceof Number) {
                // by ordinal
                int id = ((Number) o).intValue();
                if (id >= 0 && id < values().length) {
                    return values()[id];
                }
            }
            return null;
        }
    }
    public static enum Status {
        New, Accepted, Started, Review, Queued, Testing, Done, Fixed, WontFix, Duplicate, Invalid;
        public static Status fromObject(Object o) {
            if (o instanceof Status) {
                // cast and return
                return (Status) o;
            } else if (o instanceof String) {
                // find by name
                for (Status status : values()) {
                    String str = o.toString();
                    if (status.toString().equalsIgnoreCase(str)) {
                        return status;
                    }
                }
            } else if (o instanceof Number) {
                // by ordinal
                int id = ((Number) o).intValue();
                if (id >= 0 && id < values().length) {
                    return values()[id];
                }
            }
            return null;
        }
        public boolean atLeast(Status status) {
            return ordinal() >= status.ordinal();
        }
        public boolean exceeds(Status status) {
            return ordinal() > status.ordinal();
        }
        public boolean isClosed() {
            return ordinal() >= Done.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/models/SearchResult.java
New file
@@ -0,0 +1,46 @@
package com.gitblit.models;
import java.io.Serializable;
import java.util.Date;
import java.util.List;
import com.gitblit.utils.LuceneUtils.ObjectType;
/**
 * Model class that represents a search result.
 *
 * @author James Moger
 *
 */
public class SearchResult implements Serializable {
    private static final long serialVersionUID = 1L;
    public float score;
    public Date date;
    public String author;
    public String committer;
    public String summary;
    public String repository;
    public String branch;
    public String id;
    public List<String> labels;
    public ObjectType type;
    public SearchResult() {
    }
    @Override
    public String toString() {
        return  score + " : " + type.name() + " : " + repository + " : " + id + " (" + branch + ")";
    }
}
src/com/gitblit/utils/IssueUtils.java
New file
@@ -0,0 +1,823 @@
/*
 * 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.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
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 org.eclipse.jgit.treewalk.filter.AndTreeFilter;
import org.eclipse.jgit.treewalk.filter.PathFilterGroup;
import org.eclipse.jgit.treewalk.filter.TreeFilter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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.Status;
import com.gitblit.models.RefModel;
import com.gitblit.utils.JsonUtils.ExcludeField;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
/**
 * Utility class for reading Gitblit issues.
 *
 * @author James Moger
 *
 */
public class IssueUtils {
    public static interface IssueFilter {
        public abstract boolean accept(IssueModel issue);
    }
    public static final String GB_ISSUES = "refs/heads/gb-issues";
    static final Logger LOGGER = LoggerFactory.getLogger(JGitUtils.class);
    /**
     * Log an error message and exception.
     *
     * @param t
     * @param repository
     *            if repository is not null it MUST be the {0} parameter in the
     *            pattern.
     * @param pattern
     * @param objects
     */
    private static void error(Throwable t, Repository repository, String pattern, Object... objects) {
        List<Object> parameters = new ArrayList<Object>();
        if (objects != null && objects.length > 0) {
            for (Object o : objects) {
                parameters.add(o);
            }
        }
        if (repository != null) {
            parameters.add(0, repository.getDirectory().getAbsolutePath());
        }
        LOGGER.error(MessageFormat.format(pattern, parameters.toArray()), t);
    }
    /**
     * 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. Querying issues from the
     * repository requires deserializing all changes for all issues. This is an
     * expensive process and not recommended. Issues should be indexed by Lucene
     * and queries should be executed against that index.
     *
     * @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;
        }
        // Collect the set of all issue paths
        Set<String> issuePaths = new HashSet<String>();
        final TreeWalk tw = new TreeWalk(repository);
        try {
            RevCommit head = JGitUtils.getCommit(repository, GB_ISSUES);
            tw.addTree(head.getTree());
            tw.setRecursive(false);
            while (tw.next()) {
                if (tw.getDepth() < 2 && tw.isSubtree()) {
                    tw.enterSubtree();
                    if (tw.getDepth() == 2) {
                        issuePaths.add(tw.getPathString());
                    }
                }
            }
        } catch (IOException e) {
            error(e, repository, "{0} failed to query issues");
        } finally {
            tw.release();
        }
        // Build each issue and optionally filter out unwanted issues
        for (String issuePath : issuePaths) {
            RevWalk rw = new RevWalk(repository);
            try {
                RevCommit start = rw.parseCommit(repository.resolve(GB_ISSUES));
                rw.markStart(start);
            } catch (Exception e) {
                error(e, repository, "Failed to find {1} in {0}", GB_ISSUES);
            }
            TreeFilter treeFilter = AndTreeFilter.create(
                    PathFilterGroup.createFromStrings(issuePath), TreeFilter.ANY_DIFF);
            rw.setTreeFilter(treeFilter);
            Iterator<RevCommit> revlog = rw.iterator();
            List<RevCommit> commits = new ArrayList<RevCommit>();
            while (revlog.hasNext()) {
                commits.add(revlog.next());
            }
            // release the revwalk
            rw.release();
            if (commits.size() == 0) {
                LOGGER.warn("Failed to find changes for issue " + issuePath);
                continue;
            }
            // sort by commit order, first commit first
            Collections.reverse(commits);
            StringBuilder sb = new StringBuilder("[");
            boolean first = true;
            for (RevCommit commit : commits) {
                if (!first) {
                    sb.append(',');
                }
                String message = commit.getFullMessage();
                // commit message is formatted: C ISSUEID\n\nJSON
                // C is an single char commit code
                // ISSUEID is an SHA-1 hash
                String json = message.substring(43);
                sb.append(json);
                first = false;
            }
            sb.append(']');
            // Deserialize the JSON array as a Collection<Change>, this seems
            // slightly faster than deserializing each change by itself.
            Collection<Change> changes = JsonUtils.fromJsonString(sb.toString(),
                    new TypeToken<Collection<Change>>() {
                    }.getType());
            // create an issue object form the changes
            IssueModel issue = buildIssue(changes, true);
            // add the issue, conditionally, to the list
            if (filter == null) {
                list.add(issue);
            } else {
                if (filter.accept(issue)) {
                    list.add(issue);
                }
            }
        }
        // sort the issues by creation
        Collections.sort(list);
        return list;
    }
    /**
     * Retrieves the specified issue from the repository with all changes
     * applied to build the effective issue.
     *
     * @param repository
     * @param issueId
     * @return an issue, if it exists, otherwise null
     */
    public static IssueModel getIssue(Repository repository, String issueId) {
        return getIssue(repository, issueId, true);
    }
    /**
     * Retrieves the specified issue from the repository.
     *
     * @param repository
     * @param issueId
     * @param effective
     *            if true, the effective issue is built by processing comment
     *            changes, deletions, etc. if false, the raw issue is built
     *            without consideration for comment changes, deletions, etc.
     * @return an issue, if it exists, otherwise null
     */
    public static IssueModel getIssue(Repository repository, String issueId, boolean effective) {
        RefModel issuesBranch = getIssuesBranch(repository);
        if (issuesBranch == null) {
            return null;
        }
        if (StringUtils.isEmpty(issueId)) {
            return null;
        }
        String issuePath = getIssuePath(issueId);
        // Collect all changes as JSON array from commit messages
        List<RevCommit> commits = JGitUtils.getRevLog(repository, GB_ISSUES, issuePath, 0, -1);
        // sort by commit order, first commit first
        Collections.reverse(commits);
        StringBuilder sb = new StringBuilder("[");
        boolean first = true;
        for (RevCommit commit : commits) {
            if (!first) {
                sb.append(',');
            }
            String message = commit.getFullMessage();
            // commit message is formatted: C ISSUEID\n\nJSON
            // C is an single char commit code
            // ISSUEID is an SHA-1 hash
            String json = message.substring(43);
            sb.append(json);
            first = false;
        }
        sb.append(']');
        // Deserialize the JSON array as a Collection<Change>, this seems
        // slightly faster than deserializing each change by itself.
        Collection<Change> changes = JsonUtils.fromJsonString(sb.toString(),
                new TypeToken<Collection<Change>>() {
                }.getType());
        // create an issue object and apply the changes to it
        IssueModel issue = buildIssue(changes, effective);
        return issue;
    }
    /**
     * Builds an issue from a set of changes.
     *
     * @param changes
     * @param effective
     *            if true, the effective issue is built which accounts for
     *            comment changes, comment deletions, etc. if false, the raw
     *            issue is built.
     * @return an issue
     */
    private static IssueModel buildIssue(Collection<Change> changes, boolean effective) {
        IssueModel issue;
        if (effective) {
            List<Change> effectiveChanges = new ArrayList<Change>();
            Map<String, Change> comments = new HashMap<String, Change>();
            for (Change change : changes) {
                if (change.comment != null) {
                    if (comments.containsKey(change.comment.id)) {
                        Change original = comments.get(change.comment.id);
                        Change clone = DeepCopier.copy(original);
                        clone.comment.text = change.comment.text;
                        clone.comment.deleted = change.comment.deleted;
                        int idx = effectiveChanges.indexOf(original);
                        effectiveChanges.remove(original);
                        effectiveChanges.add(idx, clone);
                        comments.put(clone.comment.id, clone);
                    } else {
                        effectiveChanges.add(change);
                        comments.put(change.comment.id, change);
                    }
                } else {
                    effectiveChanges.add(change);
                }
            }
            // effective issue
            issue = new IssueModel();
            for (Change change : effectiveChanges) {
                issue.applyChange(change);
            }
        } else {
            // raw issue
            issue = new IssueModel();
            for (Change change : changes) {
                issue.applyChange(change);
            }
        }
        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
        IssueModel issue = getIssue(repository, issueId, true);
        Attachment attachment = issue.getAttachment(filename);
        // attachment not found
        if (attachment == null) {
            return null;
        }
        // retrieve the attachment content
        String issuePath = getIssuePath(issueId);
        RevTree tree = JGitUtils.getCommit(repository, GB_ISSUES).getTree();
        byte[] content = JGitUtils
                .getByteContent(repository, tree, issuePath + "/" + attachment.id);
        attachment.content = content;
        attachment.size = content.length;
        return attachment;
    }
    /**
     * Creates an issue in the gb-issues branch of the repository. The branch is
     * automatically created if it does not already exist. Your change must
     * include an author, summary, and description, at a minimum. If your change
     * does not have those minimum requirements a RuntimeException will be
     * thrown.
     *
     * @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);
        }
        if (StringUtils.isEmpty(change.author)) {
            throw new RuntimeException("Must specify a change author!");
        }
        if (!change.hasField(Field.Summary)) {
            throw new RuntimeException("Must specify a summary!");
        }
        if (!change.hasField(Field.Description)) {
            throw new RuntimeException("Must specify a description!");
        }
        change.setField(Field.Reporter, change.author);
        String issueId = StringUtils.getSHA1(change.created.toString() + change.author
                + change.getString(Field.Summary) + change.getField(Field.Description));
        change.setField(Field.Id, issueId);
        change.code = '+';
        boolean success = commit(repository, issueId, change);
        if (success) {
            return getIssue(repository, issueId, false);
        }
        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 a change author!");
        }
        // determine update code
        // default update code is '=' for a general change
        change.code = '=';
        if (change.hasField(Field.Status)) {
            Status status = Status.fromObject(change.getField(Field.Status));
            if (status.isClosed()) {
                // someone closed the issue
                change.code = 'x';
            }
        }
        success = commit(repository, issueId, change);
        return success;
    }
    /**
     * Deletes an issue from the repository.
     *
     * @param repository
     * @param issueId
     * @return true if successful
     */
    public static boolean deleteIssue(Repository repository, String issueId, String author) {
        boolean success = false;
        RefModel issuesBranch = getIssuesBranch(repository);
        if (issuesBranch == null) {
            throw new RuntimeException("gb-issues branch does not exist!");
        }
        if (StringUtils.isEmpty(issueId)) {
            throw new RuntimeException("must specify an issue id!");
        }
        String issuePath = getIssuePath(issueId);
        String message = "- " + issueId;
        try {
            ObjectId headId = repository.resolve(GB_ISSUES + "^{commit}");
            ObjectInserter odi = repository.newObjectInserter();
            try {
                // Create the in-memory index of the new/updated issue
                DirCache index = DirCache.newInCore();
                DirCacheBuilder dcBuilder = index.builder();
                // Traverse HEAD to add all other paths
                TreeWalk treeWalk = new TreeWalk(repository);
                int hIdx = -1;
                if (headId != null)
                    hIdx = treeWalk.addTree(new RevWalk(repository).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 (!path.startsWith(issuePath)) {
                        // 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();
                ObjectId indexTreeId = index.writeTree(odi);
                // Create a commit object
                PersonIdent ident = new PersonIdent(author, "gitblit@localhost");
                CommitBuilder commit = new CommitBuilder();
                commit.setAuthor(ident);
                commit.setCommitter(ident);
                commit.setEncoding(Constants.CHARACTER_ENCODING);
                commit.setMessage(message);
                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) {
            error(t, repository, "Failed to delete issue {1} to {0}", issueId);
        }
        return success;
    }
    /**
     * Changes the text of an issue comment.
     *
     * @param repository
     * @param issue
     * @param change
     *            the change with the comment to change
     * @param author
     *            the author of the revision
     * @param comment
     *            the revised comment
     * @return true, if the change was successful
     */
    public static boolean changeComment(Repository repository, IssueModel issue, Change change,
            String author, String comment) {
        Change revision = new Change(author);
        revision.comment(comment);
        revision.comment.id = change.comment.id;
        return updateIssue(repository, issue.id, revision);
    }
    /**
     * Deletes a comment from an issue.
     *
     * @param repository
     * @param issue
     * @param change
     *            the change with the comment to delete
     * @param author
     * @return true, if the deletion was successful
     */
    public static boolean deleteComment(Repository repository, IssueModel issue, Change change,
            String author) {
        Change deletion = new Change(author);
        deletion.comment(change.comment.text);
        deletion.comment.id = change.comment.id;
        deletion.comment.deleted = true;
        return updateIssue(repository, issue.id, deletion);
    }
    /**
     * Commit a change to the repository. Each issue is composed on changes.
     * Issues are built from applying the changes in the order they were
     * committed to the repository. The changes are actually specified in the
     * commit messages and not in the RevTrees which allows for clean,
     * distributed merging.
     *
     * @param repository
     * @param issue
     * @param change
     * @return true, if the change was committed
     */
    private static boolean commit(Repository repository, String issueId, Change change) {
        boolean success = false;
        try {
            // assign ids to new attachments
            // attachments are stored by an SHA1 id
            if (change.hasAttachments()) {
                for (Attachment attachment : change.attachments) {
                    if (!ArrayUtils.isEmpty(attachment.content)) {
                        byte[] prefix = (change.created.toString() + change.author).getBytes();
                        byte[] bytes = new byte[prefix.length + attachment.content.length];
                        System.arraycopy(prefix, 0, bytes, 0, prefix.length);
                        System.arraycopy(attachment.content, 0, bytes, prefix.length,
                                attachment.content.length);
                        attachment.id = "attachment-" + StringUtils.getSHA1(bytes);
                    }
                }
            }
            // serialize the change as json
            // exclude any attachment from json serialization
            Gson gson = JsonUtils.gson(new ExcludeField(
                    "com.gitblit.models.IssueModel$Attachment.content"));
            String json = gson.toJson(change);
            // include the json change in the commit message
            String issuePath = getIssuePath(issueId);
            String message = change.code + " " + issueId + "\n\n" + json;
            // Create a commit file. This is required for a proper commit and
            // ensures we can retrieve the commit log of the issue path.
            //
            // This file is NOT serialized as part of the Change object.
            switch (change.code) {
            case '+': {
                // New Issue.
                Attachment placeholder = new Attachment("issue");
                placeholder.id = placeholder.name;
                placeholder.content = "DO NOT REMOVE".getBytes(Constants.CHARACTER_ENCODING);
                change.addAttachment(placeholder);
                break;
            }
            default: {
                // Update Issue.
                String changeId = StringUtils.getSHA1(json);
                Attachment placeholder = new Attachment("change-" + changeId);
                placeholder.id = placeholder.name;
                placeholder.content = "REMOVABLE".getBytes(Constants.CHARACTER_ENCODING);
                change.addAttachment(placeholder);
                break;
            }
            }
            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, issuePath, change);
                ObjectId indexTreeId = index.writeTree(odi);
                // Create a commit object
                PersonIdent ident = new PersonIdent(change.author, "gitblit@localhost");
                CommitBuilder commit = new CommitBuilder();
                commit.setAuthor(ident);
                commit.setCommitter(ident);
                commit.setEncoding(Constants.CHARACTER_ENCODING);
                commit.setMessage(message);
                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) {
            error(t, repository, "Failed to commit issue {1} to {0}", issueId);
        }
        return success;
    }
    /**
     * 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
     */
    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 change
     * @return an in-memory index
     * @throws IOException
     */
    private static DirCache createIndex(Repository repo, ObjectId headId, String issuePath,
            Change change) throws IOException {
        DirCache inCoreIndex = DirCache.newInCore();
        DirCacheBuilder dcBuilder = inCoreIndex.builder();
        ObjectInserter inserter = repo.newObjectInserter();
        Set<String> ignorePaths = new TreeSet<String>();
        try {
            // Add any attachments to the temporary index
            if (change.hasAttachments()) {
                for (Attachment attachment : change.attachments) {
                    // build a path name for the attachment and mark as ignored
                    String path = issuePath + "/" + attachment.id;
                    ignorePaths.add(path);
                    // create an index entry for this attachment
                    final DirCacheEntry dcEntry = new DirCacheEntry(path);
                    dcEntry.setLength(attachment.content.length);
                    dcEntry.setLastModified(change.created.getTime());
                    dcEntry.setFileMode(FileMode.REGULAR_FILE);
                    // insert object
                    dcEntry.setObjectId(inserter.insert(Constants.OBJ_BLOB, attachment.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 (!ignorePaths.contains(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;
    }
}
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;
@@ -745,25 +744,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));
@@ -772,7 +786,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);
            }
@@ -1478,7 +1497,7 @@
                // Create a tree object to reference from a commit
                TreeFormatter tree = new TreeFormatter();
                tree.append("NEWBRANCH", FileMode.REGULAR_FILE, blobId);
                tree.append(".branch", FileMode.REGULAR_FILE, blobId);
                ObjectId treeId = odi.insert(tree);
                // Create a commit object
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;
@@ -172,7 +174,8 @@
        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();
    }
@@ -289,11 +295,32 @@
                JsonDeserializationContext jsonDeserializationContext) {
            try {
                synchronized (dateFormat) {
                    return dateFormat.parse(jsonElement.getAsString());
                    Date date = dateFormat.parse(jsonElement.getAsString());
                    return new Date((date.getTime() / 1000) * 1000);
                }
            } catch (ParseException e) {
                throw new JsonSyntaxException(jsonElement.getAsString(), e);
            }
        }
    }
    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));
        }
    }
}
src/com/gitblit/utils/LuceneUtils.java
New file
@@ -0,0 +1,635 @@
package com.gitblit.utils;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.text.ParseException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.ConcurrentHashMap;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.document.DateTools;
import org.apache.lucene.document.DateTools.Resolution;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.Field.Index;
import org.apache.lucene.document.Field.Store;
import org.apache.lucene.index.IndexReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.IndexWriterConfig.OpenMode;
import org.apache.lucene.index.MultiReader;
import org.apache.lucene.index.Term;
import org.apache.lucene.queryParser.QueryParser;
import org.apache.lucene.search.BooleanClause.Occur;
import org.apache.lucene.search.BooleanQuery;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.ScoreDoc;
import org.apache.lucene.search.TopScoreDocCollector;
import org.apache.lucene.store.Directory;
import org.apache.lucene.store.FSDirectory;
import org.apache.lucene.util.Version;
import org.eclipse.jgit.diff.DiffEntry.ChangeType;
import org.eclipse.jgit.lib.Constants;
import org.eclipse.jgit.lib.FileMode;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.ObjectLoader;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevObject;
import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.treewalk.TreeWalk;
import com.gitblit.models.IssueModel;
import com.gitblit.models.IssueModel.Attachment;
import com.gitblit.models.PathModel.PathChangeModel;
import com.gitblit.models.RefModel;
import com.gitblit.models.SearchResult;
/**
 * A collection of utility methods for indexing and querying a Lucene repository
 * index.
 *
 * @author James Moger
 *
 */
public class LuceneUtils {
    /**
     * The types of objects that can be indexed and queried.
     */
    public static enum ObjectType {
        commit, blob, issue;
        static ObjectType fromName(String name) {
            for (ObjectType value : values()) {
                if (value.name().equals(name)) {
                    return value;
                }
            }
            return null;
        }
    }
    private static final Version LUCENE_VERSION = Version.LUCENE_35;
    private static final String FIELD_OBJECT_TYPE = "type";
    private static final String FIELD_OBJECT_ID = "id";
    private static final String FIELD_BRANCH = "branch";
    private static final String FIELD_REPOSITORY = "repository";
    private static final String FIELD_SUMMARY = "summary";
    private static final String FIELD_CONTENT = "content";
    private static final String FIELD_AUTHOR = "author";
    private static final String FIELD_COMMITTER = "committer";
    private static final String FIELD_DATE = "date";
    private static final String FIELD_LABEL = "label";
    private static final String FIELD_ATTACHMENT = "attachment";
    private static Set<String> excludedExtensions = new TreeSet<String>(
            Arrays.asList("7z", "arc", "arj", "bin", "bmp", "dll", "doc",
                    "docx", "exe", "gif", "gz", "jar", "jpg", "lib", "lzh",
                    "odg", "pdf", "ppt", "png", "so", "swf", "xcf", "xls",
                    "xlsx", "zip"));
    private static Set<String> excludedBranches = new TreeSet<String>(
            Arrays.asList("/refs/heads/gb-issues"));
    private static final Map<File, IndexSearcher> SEARCHERS = new ConcurrentHashMap<File, IndexSearcher>();
    private static final Map<File, IndexWriter> WRITERS = new ConcurrentHashMap<File, IndexWriter>();
    /**
     * Returns the name of the repository.
     *
     * @param repository
     * @return the repository name
     */
    private static String getName(Repository repository) {
        if (repository.isBare()) {
            return repository.getDirectory().getName();
        } else {
            return repository.getDirectory().getParentFile().getName();
        }
    }
    /**
     * Deletes the Lucene index for the specified repository.
     *
     * @param repository
     * @return true, if successful
     */
    public static boolean deleteIndex(Repository repository) {
        try {
            File luceneIndex = new File(repository.getDirectory(), "lucene");
            if (luceneIndex.exists()) {
                org.eclipse.jgit.util.FileUtils.delete(luceneIndex,
                        org.eclipse.jgit.util.FileUtils.RECURSIVE);
            }
            return true;
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
    /**
     * This completely indexes the repository and will destroy any existing
     * index.
     *
     * @param repository
     * @return true if the indexing has succeeded
     */
    public static boolean index(Repository repository) {
        try {
            String repositoryName = getName(repository);
            Set<String> indexedCommits = new TreeSet<String>();
            IndexWriter writer = getIndexWriter(repository, true);
            // build a quick lookup of tags
            Map<String, List<String>> tags = new HashMap<String, List<String>>();
            for (RefModel tag : JGitUtils.getTags(repository, false, -1)) {
                if (!tags.containsKey(tag.getObjectId())) {
                    tags.put(tag.getReferencedObjectId().getName(), new ArrayList<String>());
                }
                tags.get(tag.getReferencedObjectId().getName()).add(tag.displayName);
            }
            // walk through each branch
            List<RefModel> branches = JGitUtils.getLocalBranches(repository, true, -1);
            for (RefModel branch : branches) {
                if (excludedBranches.contains(branch.getName())) {
                    continue;
                }
                String branchName = branch.getName();
                RevWalk revWalk = new RevWalk(repository);
                RevCommit rev = revWalk.parseCommit(branch.getObjectId());
                // index the blob contents of the tree
                ByteArrayOutputStream os = new ByteArrayOutputStream();
                byte[] tmp = new byte[32767];
                TreeWalk treeWalk = new TreeWalk(repository);
                treeWalk.addTree(rev.getTree());
                treeWalk.setRecursive(true);
                String revDate = DateTools.timeToString(rev.getCommitTime() * 1000L,
                        Resolution.MINUTE);
                while (treeWalk.next()) {
                    Document doc = new Document();
                    doc.add(new Field(FIELD_OBJECT_TYPE, ObjectType.blob.name(), Store.YES,
                            Index.NOT_ANALYZED_NO_NORMS));
                    doc.add(new Field(FIELD_REPOSITORY, repositoryName, Store.YES,
                            Index.NOT_ANALYZED));
                    doc.add(new Field(FIELD_BRANCH, branchName, Store.YES,
                            Index.NOT_ANALYZED));
                    doc.add(new Field(FIELD_OBJECT_ID, treeWalk.getPathString(), Store.YES,
                            Index.NOT_ANALYZED));
                    doc.add(new Field(FIELD_DATE, revDate, Store.YES, Index.NO));
                    doc.add(new Field(FIELD_AUTHOR, rev.getAuthorIdent().getName(), Store.YES,
                            Index.NOT_ANALYZED_NO_NORMS));
                    doc.add(new Field(FIELD_COMMITTER, rev.getCommitterIdent().getName(),
                            Store.YES, Index.NOT_ANALYZED_NO_NORMS));
                    doc.add(new Field(FIELD_LABEL, branch.getName(), Store.YES, Index.ANALYZED));
                    // determine extension to compare to the extension
                    // blacklist
                    String ext = null;
                    String name = treeWalk.getPathString().toLowerCase();
                    if (name.indexOf('.') > -1) {
                        ext = name.substring(name.lastIndexOf('.') + 1);
                    }
                    if (StringUtils.isEmpty(ext) || !excludedExtensions.contains(ext)) {
                        // read the blob content
                        ObjectId entid = treeWalk.getObjectId(0);
                        FileMode entmode = treeWalk.getFileMode(0);
                        RevObject ro = revWalk.lookupAny(entid, entmode.getObjectType());
                        revWalk.parseBody(ro);
                        ObjectLoader ldr = repository.open(ro.getId(), Constants.OBJ_BLOB);
                        InputStream in = ldr.openStream();
                        os.reset();
                        int n = 0;
                        while ((n = in.read(tmp)) > 0) {
                            os.write(tmp, 0, n);
                        }
                        in.close();
                        byte[] content = os.toByteArray();
                        String str = new String(content, "UTF-8");
                        doc.add(new Field(FIELD_CONTENT, str, Store.NO, Index.ANALYZED));
                        writer.addDocument(doc);
                    }
                }
                os.close();
                treeWalk.release();
                // index the head commit object
                String head = rev.getId().getName();
                if (indexedCommits.add(head)) {
                    Document doc = createDocument(rev, tags.get(head));
                    doc.add(new Field(FIELD_REPOSITORY, repositoryName, Store.YES,
                            Index.NOT_ANALYZED));
                    doc.add(new Field(FIELD_BRANCH, branchName, Store.YES,
                            Index.NOT_ANALYZED));
                    writer.addDocument(doc);
                }
                // traverse the log and index the previous commit objects
                revWalk.markStart(rev);
                while ((rev = revWalk.next()) != null) {
                    String hash = rev.getId().getName();
                    if (indexedCommits.add(hash)) {
                        Document doc = createDocument(rev, tags.get(hash));
                        doc.add(new Field(FIELD_REPOSITORY, repositoryName, Store.YES,
                                Index.NOT_ANALYZED));
                        doc.add(new Field(FIELD_BRANCH, branchName, Store.YES,
                                Index.NOT_ANALYZED));
                        writer.addDocument(doc);
                    }
                }
                // finished
                revWalk.dispose();
            }
            // this repository has a gb-issues branch, index all issues
            if (IssueUtils.getIssuesBranch(repository) != null) {
                List<IssueModel> issues = IssueUtils.getIssues(repository, null);
                for (IssueModel issue : issues) {
                    Document doc = createDocument(issue);
                    doc.add(new Field(FIELD_REPOSITORY, repositoryName, Store.YES,
                            Index.NOT_ANALYZED));
                    writer.addDocument(doc);
                }
            }
            // commit all changes and reset the searcher
            resetIndexSearcher(repository);
            writer.commit();
            return true;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return false;
    }
    /**
     * Incrementally update the index with the specified commit for the
     * repository.
     *
     * @param repository
     * @param branch
     *            the fully qualified branch name (e.g. refs/heads/master)
     * @param commit
     * @return true, if successful
     */
    public static boolean index(Repository repository, String branch, RevCommit commit) {
        try {
            if (excludedBranches.contains(branch)) {
                if (IssueUtils.GB_ISSUES.equals(branch)) {
                    // index an issue
                    String issueId = commit.getShortMessage().substring(2).trim();
                    IssueModel issue = IssueUtils.getIssue(repository, issueId);
                    return index(repository, issue, true);
                }
                return false;
            }
            List<PathChangeModel> changedPaths = JGitUtils.getFilesInCommit(repository, commit);
            String repositoryName = getName(repository);
            String revDate = DateTools.timeToString(commit.getCommitTime() * 1000L,
                    Resolution.MINUTE);
            IndexWriter writer = getIndexWriter(repository, false);
            for (PathChangeModel path : changedPaths) {
                // delete the indexed blob
                writer.deleteDocuments(new Term(FIELD_OBJECT_TYPE, ObjectType.blob.name()),
                        new Term(FIELD_BRANCH, branch),
                        new Term(FIELD_OBJECT_ID, path.path));
                // re-index the blob
                if (!ChangeType.DELETE.equals(path.changeType)) {
                    Document doc = new Document();
                    doc.add(new Field(FIELD_OBJECT_TYPE, ObjectType.blob.name(), Store.YES,
                            Index.NOT_ANALYZED_NO_NORMS));
                    doc.add(new Field(FIELD_REPOSITORY, repositoryName, Store.YES,
                            Index.NOT_ANALYZED));
                    doc.add(new Field(FIELD_BRANCH, branch, Store.YES, Index.NOT_ANALYZED));
                    doc.add(new Field(FIELD_OBJECT_ID, path.path, Store.YES,
                            Index.NOT_ANALYZED));
                    doc.add(new Field(FIELD_DATE, revDate, Store.YES, Index.NO));
                    doc.add(new Field(FIELD_AUTHOR, commit.getAuthorIdent().getName(), Store.YES,
                            Index.NOT_ANALYZED_NO_NORMS));
                    doc.add(new Field(FIELD_COMMITTER, commit.getCommitterIdent().getName(),
                            Store.YES, Index.NOT_ANALYZED_NO_NORMS));
                    doc.add(new Field(FIELD_LABEL, branch, Store.YES, Index.ANALYZED));
                    // determine extension to compare to the extension
                    // blacklist
                    String ext = null;
                    String name = path.name.toLowerCase();
                    if (name.indexOf('.') > -1) {
                        ext = name.substring(name.lastIndexOf('.') + 1);
                    }
                    if (StringUtils.isEmpty(ext) || !excludedExtensions.contains(ext)) {
                        // read the blob content
                        String str = JGitUtils.getStringContent(repository,
                                commit.getTree(), path.path);
                        doc.add(new Field(FIELD_CONTENT, str, Store.NO, Index.ANALYZED));
                        writer.addDocument(doc);
                    }
                }
            }
            writer.commit();
            Document doc = createDocument(commit, null);
            return index(repository, doc);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return false;
    }
    /**
     * Incrementally update the index with the specified issue for the
     * repository.
     *
     * @param repository
     * @param issue
     * @param reindex
     *            if true, the old index entry for this issue will be deleted.
     *            This is only appropriate for pre-existing/indexed issues.
     * @return true, if successful
     */
    public static boolean index(Repository repository, IssueModel issue, boolean reindex) {
        try {
            Document doc = createDocument(issue);
            if (reindex) {
                // delete the old issue from the index, if exists
                IndexWriter writer = getIndexWriter(repository, false);
                writer.deleteDocuments(new Term(FIELD_OBJECT_TYPE, ObjectType.issue.name()),
                        new Term(FIELD_OBJECT_ID, String.valueOf(issue.id)));
                writer.commit();
            }
            return index(repository, doc);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return false;
    }
    /**
     * Creates a Lucene document from an issue.
     *
     * @param issue
     * @return a Lucene document
     */
    private static Document createDocument(IssueModel issue) {
        Document doc = new Document();
        doc.add(new Field(FIELD_OBJECT_TYPE, ObjectType.issue.name(), Store.YES,
                Field.Index.NOT_ANALYZED_NO_NORMS));
        doc.add(new Field(FIELD_OBJECT_ID, issue.id, Store.YES, Index.NOT_ANALYZED));
        doc.add(new Field(FIELD_BRANCH, IssueUtils.GB_ISSUES, Store.YES, Index.NOT_ANALYZED));
        doc.add(new Field(FIELD_DATE, DateTools.dateToString(issue.created, Resolution.MINUTE),
                Store.YES, Field.Index.NO));
        doc.add(new Field(FIELD_AUTHOR, issue.reporter, Store.YES, Index.NOT_ANALYZED_NO_NORMS));
        List<String> attachments = new ArrayList<String>();
        for (Attachment attachment : issue.getAttachments()) {
            attachments.add(attachment.name.toLowerCase());
        }
        doc.add(new Field(FIELD_ATTACHMENT, StringUtils.flattenStrings(attachments), Store.YES,
                Index.ANALYZED));
        doc.add(new Field(FIELD_SUMMARY, issue.summary, Store.YES, Index.ANALYZED));
        doc.add(new Field(FIELD_CONTENT, issue.toString(), Store.NO, Index.ANALYZED));
        doc.add(new Field(FIELD_LABEL, StringUtils.flattenStrings(issue.getLabels()), Store.YES,
                Index.ANALYZED));
        return doc;
    }
    /**
     * Creates a Lucene document for a commit
     *
     * @param commit
     * @param tags
     * @return a Lucene document
     */
    private static Document createDocument(RevCommit commit, List<String> tags) {
        Document doc = new Document();
        doc.add(new Field(FIELD_OBJECT_TYPE, ObjectType.commit.name(), Store.YES,
                Index.NOT_ANALYZED_NO_NORMS));
        doc.add(new Field(FIELD_OBJECT_ID, commit.getName(), Store.YES, Index.NOT_ANALYZED));
        doc.add(new Field(FIELD_DATE, DateTools.timeToString(commit.getCommitTime() * 1000L,
                Resolution.MINUTE), Store.YES, Index.NO));
        doc.add(new Field(FIELD_AUTHOR, commit.getCommitterIdent().getName(), Store.YES,
                Index.NOT_ANALYZED_NO_NORMS));
        doc.add(new Field(FIELD_SUMMARY, commit.getShortMessage(), Store.YES, Index.ANALYZED));
        doc.add(new Field(FIELD_CONTENT, commit.getFullMessage(), Store.NO, Index.ANALYZED));
        if (!ArrayUtils.isEmpty(tags)) {
            if (!ArrayUtils.isEmpty(tags)) {
                doc.add(new Field(FIELD_LABEL, StringUtils.flattenStrings(tags), Store.YES,
                        Index.ANALYZED));
            }
        }
        return doc;
    }
    /**
     * Incrementally index an object for the repository.
     *
     * @param repository
     * @param doc
     * @return true, if successful
     */
    private static boolean index(Repository repository, Document doc) {
        try {
            String repositoryName = getName(repository);
            doc.add(new Field(FIELD_REPOSITORY, repositoryName, Store.YES,
                    Index.NOT_ANALYZED));
            IndexWriter writer = getIndexWriter(repository, false);
            writer.addDocument(doc);
            resetIndexSearcher(repository);
            writer.commit();
            return true;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return false;
    }
    private static SearchResult createSearchResult(Document doc, float score) throws ParseException {
        SearchResult result = new SearchResult();
        result.score = score;
        result.date = DateTools.stringToDate(doc.get(FIELD_DATE));
        result.summary = doc.get(FIELD_SUMMARY);
        result.author = doc.get(FIELD_AUTHOR);
        result.committer = doc.get(FIELD_COMMITTER);
        result.type = ObjectType.fromName(doc.get(FIELD_OBJECT_TYPE));
        result.repository = doc.get(FIELD_REPOSITORY);
        result.branch = doc.get(FIELD_BRANCH);
        result.id = doc.get(FIELD_OBJECT_ID);
        if (doc.get(FIELD_LABEL) != null) {
            result.labels = StringUtils.getStringsFromValue(doc.get(FIELD_LABEL));
        }
        return result;
    }
    private static void resetIndexSearcher(Repository repository) throws IOException {
        IndexSearcher searcher = SEARCHERS.get(repository.getDirectory());
        if (searcher != null) {
            SEARCHERS.remove(repository.getDirectory());
            searcher.close();
        }
    }
    /**
     * Gets an index searcher for the repository.
     *
     * @param repository
     * @return
     * @throws IOException
     */
    private static IndexSearcher getIndexSearcher(Repository repository) throws IOException {
        IndexSearcher searcher = SEARCHERS.get(repository.getDirectory());
        if (searcher == null) {
            IndexWriter writer = getIndexWriter(repository, false);
            searcher = new IndexSearcher(IndexReader.open(writer, true));
            SEARCHERS.put(repository.getDirectory(), searcher);
        }
        return searcher;
    }
    /**
     * Gets an index writer for the repository. The index will be created if it
     * does not already exist or if forceCreate is specified.
     *
     * @param repository
     * @param forceCreate
     * @return an IndexWriter
     * @throws IOException
     */
    private static IndexWriter getIndexWriter(Repository repository, boolean forceCreate)
            throws IOException {
        IndexWriter indexWriter = WRITERS.get(repository.getDirectory());
        File indexFolder = new File(repository.getDirectory(), "lucene");
        Directory directory = FSDirectory.open(indexFolder);
        if (forceCreate || !indexFolder.exists()) {
            // if the writer is going to blow away the existing index and create
            // a new one then it should not be cached. instead, close any open
            // writer, create a new one, and return.
            if (indexWriter != null) {
                indexWriter.close();
                indexWriter = null;
                WRITERS.remove(repository.getDirectory());
            }
            indexFolder.mkdirs();
            IndexWriterConfig config = new IndexWriterConfig(LUCENE_VERSION, new StandardAnalyzer(
                    LUCENE_VERSION));
            config.setOpenMode(OpenMode.CREATE);
            IndexWriter writer = new IndexWriter(directory, config);
            writer.close();
        }
        if (indexWriter == null) {
            IndexWriterConfig config = new IndexWriterConfig(LUCENE_VERSION, new StandardAnalyzer(
                    LUCENE_VERSION));
            config.setOpenMode(OpenMode.APPEND);
            indexWriter = new IndexWriter(directory, config);
            WRITERS.put(repository.getDirectory(), indexWriter);
        }
        return indexWriter;
    }
    /**
     * Searches the specified repositories for the given text or query
     *
     * @param text
     *            if the text is null or empty, null is returned
     * @param maximumHits
     *            the maximum number of hits to collect
     * @param repositories
     *            a list of repositories to search. if no repositories are
     *            specified null is returned.
     * @return a list of SearchResults in order from highest to the lowest score
     *
     */
    public static List<SearchResult> search(String text, int maximumHits,
            Repository... repositories) {
        if (StringUtils.isEmpty(text)) {
            return null;
        }
        if (repositories.length == 0) {
            return null;
        }
        Set<SearchResult> results = new LinkedHashSet<SearchResult>();
        StandardAnalyzer analyzer = new StandardAnalyzer(LUCENE_VERSION);
        try {
            // default search checks summary and content
            BooleanQuery query = new BooleanQuery();
            QueryParser qp;
            qp = new QueryParser(LUCENE_VERSION, FIELD_SUMMARY, analyzer);
            qp.setAllowLeadingWildcard(true);
            query.add(qp.parse(text), Occur.SHOULD);
            qp = new QueryParser(LUCENE_VERSION, FIELD_CONTENT, analyzer);
            qp.setAllowLeadingWildcard(true);
            query.add(qp.parse(text), Occur.SHOULD);
            IndexSearcher searcher;
            if (repositories.length == 1) {
                // single repository search
                searcher = getIndexSearcher(repositories[0]);
            } else {
                // multiple repository search
                List<IndexReader> readers = new ArrayList<IndexReader>();
                for (Repository repository : repositories) {
                    IndexSearcher repositoryIndex = getIndexSearcher(repository);
                    readers.add(repositoryIndex.getIndexReader());
                }
                IndexReader [] rdrs = readers.toArray(new IndexReader[readers.size()]);
                MultiReader reader = new MultiReader(rdrs);
                searcher = new IndexSearcher(reader);
            }
            Query rewrittenQuery = searcher.rewrite(query);
            TopScoreDocCollector collector = TopScoreDocCollector.create(maximumHits, true);
            searcher.search(rewrittenQuery, collector);
            ScoreDoc[] hits = collector.topDocs().scoreDocs;
            for (int i = 0; i < hits.length; i++) {
                int docId = hits[i].doc;
                Document doc = searcher.doc(docId);
                SearchResult result = createSearchResult(doc, hits[i].score);
                results.add(result);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
        return new ArrayList<SearchResult>(results);
    }
    /**
     * Close all the index writers and searchers
     */
    public static void close() {
        // close writers
        for (File file : WRITERS.keySet()) {
            try {
                WRITERS.get(file).close(true);
            } catch (Throwable t) {
                t.printStackTrace();
            }
        }
        WRITERS.clear();
        // close searchers
        for (File file : SEARCHERS.keySet()) {
            try {
                SEARCHERS.get(file).close();
            } catch (Throwable t) {
                t.printStackTrace();
            }
        }
        SEARCHERS.clear();
    }
}
tests/com/gitblit/tests/GitBlitSuite.java
@@ -52,7 +52,7 @@
        ObjectCacheTest.class, UserServiceTest.class, MarkdownUtilsTest.class, JGitUtilsTest.class,
        SyndicationUtilsTest.class, DiffUtilsTest.class, MetricUtilsTest.class,
        TicgitUtilsTest.class, GitBlitTest.class, FederationTests.class, RpcTests.class,
        GitServletTest.class, GroovyScriptTest.class })
        GitServletTest.class, GroovyScriptTest.class, LuceneUtilsTest.class, IssuesTest.class })
public class GitBlitSuite {
    public static final File REPOSITORIES = new File("git");
@@ -88,6 +88,10 @@
    public static Repository getTheoreticalPhysicsRepository() throws Exception {
        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 {
@@ -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,256 @@
/*
 * 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.assertFalse;
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.models.IssueModel.Status;
import com.gitblit.models.SearchResult;
import com.gitblit.utils.IssueUtils;
import com.gitblit.utils.IssueUtils.IssueFilter;
import com.gitblit.utils.LuceneUtils;
/**
 * Tests the mechanics of distributed issue management on the gb-issues branch.
 *
 * @author James Moger
 *
 */
public class IssuesTest {
    @Test
    public void testCreation() throws Exception {
        Repository repository = GitBlitSuite.getIssuesTestRepository();
        // create and insert the issue
        Change c1 = newChange("testCreation() " + 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);
        assertEquals(1, constructed.changes.size());
    }
    @Test
    public void testUpdates() throws Exception {
        Repository repository = GitBlitSuite.getIssuesTestRepository();
        // C1: create the issue
        Change c1 = newChange("testUpdates() " + Long.toHexString(System.currentTimeMillis()));
        IssueModel issue = IssueUtils.createIssue(repository, c1);
        assertNotNull(issue.id);
        IssueModel constructed = IssueUtils.getIssue(repository, issue.id);
        compare(issue, constructed);
        // C2: set owner
        Change c2 = new Change("C2");
        c2.comment("I'll fix this");
        c2.setField(Field.Owner, c2.author);
        assertTrue(IssueUtils.updateIssue(repository, issue.id, c2));
        constructed = IssueUtils.getIssue(repository, issue.id);
        assertEquals(2, constructed.changes.size());
        assertEquals(c2.author, constructed.owner);
        // C3: add a note
        Change c3 = new Change("C3");
        c3.comment("yeah, this is working");
        assertTrue(IssueUtils.updateIssue(repository, issue.id, c3));
        constructed = IssueUtils.getIssue(repository, issue.id);
        assertEquals(3, constructed.changes.size());
        // C4: add attachment
        Change c4 = new Change("C4");
        Attachment a = newAttachment();
        c4.addAttachment(a);
        assertTrue(IssueUtils.updateIssue(repository, issue.id, c4));
        Attachment a1 = IssueUtils.getIssueAttachment(repository, issue.id, a.name);
        assertEquals(a.content.length, a1.content.length);
        assertTrue(Arrays.areEqual(a.content, a1.content));
        // C5: close the issue
        Change c5 = new Change("C5");
        c5.comment("closing issue");
        c5.setField(Field.Status, Status.Fixed);
        assertTrue(IssueUtils.updateIssue(repository, issue.id, c5));
        // retrieve issue again
        constructed = IssueUtils.getIssue(repository, issue.id);
        assertEquals(5, constructed.changes.size());
        assertTrue(constructed.status.isClosed());
        repository.close();
    }
    @Test
    public void testQuery() throws Exception {
        Repository repository = GitBlitSuite.getIssuesTestRepository();
        List<IssueModel> allIssues = IssueUtils.getIssues(repository, null);
        List<IssueModel> openIssues = IssueUtils.getIssues(repository, new IssueFilter() {
            @Override
            public boolean accept(IssueModel issue) {
                return !issue.status.isClosed();
            }
        });
        List<IssueModel> closedIssues = IssueUtils.getIssues(repository, new IssueFilter() {
            @Override
            public boolean accept(IssueModel issue) {
                return issue.status.isClosed();
            }
        });
        repository.close();
        assertTrue(allIssues.size() > 0);
        assertEquals(1, openIssues.size());
        assertEquals(1, closedIssues.size());
    }
    @Test
    public void testLuceneIndexAndQuery() throws Exception {
        Repository repository = GitBlitSuite.getIssuesTestRepository();
        LuceneUtils.deleteIndex(repository);
        List<IssueModel> allIssues = IssueUtils.getIssues(repository, null);
        assertTrue(allIssues.size() > 0);
        for (IssueModel issue : allIssues) {
            LuceneUtils.index(repository, issue, false);
        }
        List<SearchResult> hits = LuceneUtils.search("working", 10, repository);
        assertTrue(hits.size() > 0);
        // reindex an issue
        IssueModel issue = allIssues.get(0);
        Change change = new Change("reindex");
        change.comment("this is a test of reindexing an issue");
        IssueUtils.updateIssue(repository, issue.id, change);
        issue = IssueUtils.getIssue(repository, issue.id);
        LuceneUtils.index(repository, issue, true);
        LuceneUtils.close();
        repository.close();
    }
    @Test
    public void testLuceneQuery() throws Exception {
        Repository repository = GitBlitSuite.getIssuesTestRepository();
        List<SearchResult> hits = LuceneUtils.search("working", 10, repository);
        LuceneUtils.close();
        repository.close();
        assertTrue(hits.size() > 0);
    }
    @Test
    public void testDelete() throws Exception {
        Repository repository = GitBlitSuite.getIssuesTestRepository();
        List<IssueModel> allIssues = IssueUtils.getIssues(repository, null);
        // delete all issues
        for (IssueModel issue : allIssues) {
            assertTrue(IssueUtils.deleteIssue(repository, issue.id, "D"));
        }
        repository.close();
    }
    @Test
    public void testChangeComment() throws Exception {
        Repository repository = GitBlitSuite.getIssuesTestRepository();
        // C1: create the issue
        Change c1 = newChange("testChangeComment() " + Long.toHexString(System.currentTimeMillis()));
        IssueModel issue = IssueUtils.createIssue(repository, c1);
        assertNotNull(issue.id);
        assertTrue(issue.changes.get(0).hasComment());
        assertTrue(IssueUtils.changeComment(repository, issue, c1, "E1", "I changed the comment"));
        issue = IssueUtils.getIssue(repository, issue.id);
        assertTrue(issue.changes.get(0).hasComment());
        assertEquals("I changed the comment", issue.changes.get(0).comment.text);
        assertTrue(IssueUtils.deleteIssue(repository, issue.id, "D"));
        repository.close();
    }
    @Test
    public void testDeleteComment() throws Exception {
        Repository repository = GitBlitSuite.getIssuesTestRepository();
        // C1: create the issue
        Change c1 = newChange("testDeleteComment() " + Long.toHexString(System.currentTimeMillis()));
        IssueModel issue = IssueUtils.createIssue(repository, c1);
        assertNotNull(issue.id);
        assertTrue(issue.changes.get(0).hasComment());
        assertTrue(IssueUtils.deleteComment(repository, issue, c1, "D1"));
        issue = IssueUtils.getIssue(repository, issue.id);
        assertEquals(1, issue.changes.size());
        assertFalse(issue.changes.get(0).hasComment());
        issue = IssueUtils.getIssue(repository, issue.id, false);
        assertEquals(2, issue.changes.size());
        assertTrue(issue.changes.get(0).hasComment());
        assertFalse(issue.changes.get(1).hasComment());
        assertTrue(IssueUtils.deleteIssue(repository, issue.id, "D"));
        repository.close();
    }
    private Change newChange(String summary) {
        Change change = new Change("C1");
        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");
        return change;
    }
    private Attachment newAttachment() {
        Attachment attachment = new Attachment(Long.toHexString(System.currentTimeMillis())
                + ".txt");
        attachment.content = new byte[] { 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49,
                0x4a };
        return attachment;
    }
    private void compare(IssueModel issue, IssueModel constructed) {
        assertEquals(issue.id, constructed.id);
        assertEquals(issue.reporter, constructed.reporter);
        assertEquals(issue.owner, constructed.owner);
        assertEquals(issue.summary, constructed.summary);
        assertEquals(issue.description, constructed.description);
        assertEquals(issue.created, constructed.created);
        assertTrue(issue.hasLabel("helpdesk"));
    }
}
tests/com/gitblit/tests/LuceneUtilsTest.java
New file
@@ -0,0 +1,118 @@
/*
 * 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 java.util.List;
import org.eclipse.jgit.lib.Repository;
import org.junit.Test;
import com.gitblit.models.SearchResult;
import com.gitblit.utils.LuceneUtils;
/**
 * Tests Lucene indexing and querying.
 *
 * @author James Moger
 *
 */
public class LuceneUtilsTest {
    @Test
    public void testFullIndex() throws Exception {
        // reindex helloworld
        Repository repository = GitBlitSuite.getHelloworldRepository();
        LuceneUtils.index(repository);
        repository.close();
        // reindex theoretical physics
        repository = GitBlitSuite.getTheoreticalPhysicsRepository();
        LuceneUtils.index(repository);
        repository.close();
        // reindex bluez-gnome
        repository = GitBlitSuite.getBluezGnomeRepository();
        LuceneUtils.index(repository);
        repository.close();
        LuceneUtils.close();
    }
    @Test
    public void testQuery() throws Exception {
        // 2 occurrences on the master branch
        Repository repository = GitBlitSuite.getHelloworldRepository();
        List<SearchResult> results = LuceneUtils.search("ada", 10, repository);
        assertEquals(2, results.size());
        // author test
        results = LuceneUtils.search("author: tinogomes", 10, repository);
        assertEquals(2, results.size());
        repository.close();
        // blob test
        results = LuceneUtils.search("type: blob AND \"import std.stdio\"", 10, repository);
        assertEquals(1, results.size());
        assertEquals("d.D", results.get(0).id);
        // 1 occurrence on the gh-pages branch
        repository = GitBlitSuite.getTheoreticalPhysicsRepository();
        results = LuceneUtils.search("\"add the .nojekyll file\"", 10, repository);
        assertEquals(1, results.size());
        assertEquals("Ondrej Certik", results.get(0).author);
        assertEquals("2648c0c98f2101180715b4d432fc58d0e21a51d7", results.get(0).id);
        // tag test
        results = LuceneUtils.search("\"qft split\"", 10, repository);
        assertEquals(1, results.size());
        assertEquals("Ondrej Certik", results.get(0).author);
        assertEquals("57c4f26f157ece24b02f4f10f5f68db1d2ce7ff5", results.get(0).id);
        assertEquals("[1st-edition]", results.get(0).labels.toString());
        results = LuceneUtils.search("type:blob AND \"src/intro.rst\"", 10, repository);
        assertEquals(4, results.size());
        // hash id tests
        results = LuceneUtils.search("id:57c4f26f157ece24b02f4f10f5f68db1d2ce7ff5", 10, repository);
        assertEquals(1, results.size());
        results = LuceneUtils.search("id:57c4f26f157*", 10, repository);
        assertEquals(1, results.size());
        repository.close();
        // annotated tag test
        repository = GitBlitSuite.getBluezGnomeRepository();
        results = LuceneUtils.search("\"release 1.8\"", 10, repository);
        assertEquals(1, results.size());
        assertEquals("[1.8]", results.get(0).labels.toString());
        repository.close();
        LuceneUtils.close();
    }
    @Test
    public void testMultiSearch() throws Exception {
        List<SearchResult> results = LuceneUtils.search("test", 10,
                GitBlitSuite.getHelloworldRepository(),
                GitBlitSuite.getBluezGnomeRepository());
        LuceneUtils.close();
        assertEquals(10, results.size());
    }
}