summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.classpath1
-rw-r--r--src/com/gitblit/build/Build.java6
-rw-r--r--src/com/gitblit/models/IssueModel.java532
-rw-r--r--src/com/gitblit/models/SearchResult.java46
-rw-r--r--src/com/gitblit/utils/IssueUtils.java823
-rw-r--r--src/com/gitblit/utils/JGitUtils.java35
-rw-r--r--src/com/gitblit/utils/JsonUtils.java37
-rw-r--r--src/com/gitblit/utils/LuceneUtils.java635
-rw-r--r--tests/com/gitblit/tests/GitBlitSuite.java8
-rw-r--r--tests/com/gitblit/tests/IssuesTest.java256
-rw-r--r--tests/com/gitblit/tests/LuceneUtilsTest.java118
11 files changed, 2483 insertions, 14 deletions
diff --git a/.classpath b/.classpath
index d8cd9093..e74b86b9 100644
--- a/.classpath
+++ b/.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>
diff --git a/src/com/gitblit/build/Build.java b/src/com/gitblit/build/Build.java
index 4e8190a7..233451e7 100644
--- a/src/com/gitblit/build/Build.java
+++ b/src/com/gitblit/build/Build.java
@@ -91,6 +91,7 @@ public class Build {
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 @@ public class Build {
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 @@ public class Build {
"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;
diff --git a/src/com/gitblit/models/IssueModel.java b/src/com/gitblit/models/IssueModel.java
new file mode 100644
index 00000000..3c191e24
--- /dev/null
+++ b/src/com/gitblit/models/IssueModel.java
@@ -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;
+ }
+ }
+}
diff --git a/src/com/gitblit/models/SearchResult.java b/src/com/gitblit/models/SearchResult.java
new file mode 100644
index 00000000..2fa0db42
--- /dev/null
+++ b/src/com/gitblit/models/SearchResult.java
@@ -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 + ")";
+ }
+} \ No newline at end of file
diff --git a/src/com/gitblit/utils/IssueUtils.java b/src/com/gitblit/utils/IssueUtils.java
new file mode 100644
index 00000000..eb3b347b
--- /dev/null
+++ b/src/com/gitblit/utils/IssueUtils.java
@@ -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;
+ }
+} \ No newline at end of file
diff --git a/src/com/gitblit/utils/JGitUtils.java b/src/com/gitblit/utils/JGitUtils.java
index 1c155ff4..a9b99a93 100644
--- a/src/com/gitblit/utils/JGitUtils.java
+++ b/src/com/gitblit/utils/JGitUtils.java
@@ -24,7 +24,6 @@ import java.nio.charset.Charset;
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 @@ public class JGitUtils {
}
/**
- * 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 @@ public class JGitUtils {
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 @@ public class JGitUtils {
// 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
diff --git a/src/com/gitblit/utils/JsonUtils.java b/src/com/gitblit/utils/JsonUtils.java
index da9c99d2..bc9a1e00 100644
--- a/src/com/gitblit/utils/JsonUtils.java
+++ b/src/com/gitblit/utils/JsonUtils.java
@@ -38,6 +38,8 @@ import com.gitblit.GitBlitException.UnauthorizedException;
import com.gitblit.GitBlitException.UnknownRequestException;
import com.gitblit.models.RepositoryModel;
import com.gitblit.models.UserModel;
+import com.google.gson.ExclusionStrategy;
+import com.google.gson.FieldAttributes;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonDeserializationContext;
@@ -108,7 +110,7 @@ public class JsonUtils {
UnauthorizedException {
return retrieveJson(url, type, null, null);
}
-
+
/**
* Reads a gson object from the specified url.
*
@@ -169,10 +171,11 @@ public class JsonUtils {
*/
public static String retrieveJsonString(String url, String username, char[] password)
throws IOException {
- try {
+ try {
URLConnection conn = ConnectionUtils.openReadConnection(url, username, password);
InputStream is = conn.getInputStream();
- BufferedReader reader = new BufferedReader(new InputStreamReader(is, ConnectionUtils.CHARSET));
+ BufferedReader reader = new BufferedReader(new InputStreamReader(is,
+ ConnectionUtils.CHARSET));
StringBuilder json = new StringBuilder();
char[] buffer = new char[4096];
int len = 0;
@@ -260,10 +263,13 @@ public class JsonUtils {
// 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 @@ public class JsonUtils {
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));
+ }
+ }
}
diff --git a/src/com/gitblit/utils/LuceneUtils.java b/src/com/gitblit/utils/LuceneUtils.java
new file mode 100644
index 00000000..738382a4
--- /dev/null
+++ b/src/com/gitblit/utils/LuceneUtils.java
@@ -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();
+ }
+}
diff --git a/tests/com/gitblit/tests/GitBlitSuite.java b/tests/com/gitblit/tests/GitBlitSuite.java
index 71947e14..8fac212c 100644
--- a/tests/com/gitblit/tests/GitBlitSuite.java
+++ b/tests/com/gitblit/tests/GitBlitSuite.java
@@ -52,7 +52,7 @@ import com.gitblit.utils.JGitUtils;
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");
@@ -90,6 +90,10 @@ public class GitBlitSuite {
return new FileRepository(new File(REPOSITORIES, "test/theoretical-physics.git"));
}
+ public static Repository getIssuesTestRepository() throws Exception {
+ return new FileRepository(new File(REPOSITORIES, "gb-issues.git"));
+ }
+
public static boolean startGitblit() throws Exception {
if (started.get()) {
// already started
@@ -134,6 +138,8 @@ public class GitBlitSuite {
cloneOrFetch("test/ambition.git", "https://github.com/defunkt/ambition.git");
cloneOrFetch("test/theoretical-physics.git", "https://github.com/certik/theoretical-physics.git");
+ JGitUtils.createRepository(REPOSITORIES, "gb-issues.git").close();
+
enableTickets("ticgit.git");
enableDocs("ticgit.git");
showRemoteBranches("ticgit.git");
diff --git a/tests/com/gitblit/tests/IssuesTest.java b/tests/com/gitblit/tests/IssuesTest.java
new file mode 100644
index 00000000..a5d487d8
--- /dev/null
+++ b/tests/com/gitblit/tests/IssuesTest.java
@@ -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"));
+ }
+} \ No newline at end of file
diff --git a/tests/com/gitblit/tests/LuceneUtilsTest.java b/tests/com/gitblit/tests/LuceneUtilsTest.java
new file mode 100644
index 00000000..a5446218
--- /dev/null
+++ b/tests/com/gitblit/tests/LuceneUtilsTest.java
@@ -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());
+ }
+} \ No newline at end of file