/* * 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.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.internal.JGitText; 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/gitblit/issues"; static final Logger LOGGER = LoggerFactory.getLogger(IssueUtils.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 parameters = new ArrayList(); 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) { List refs = JGitUtils.getRefs(repository, com.gitblit.Constants.R_GITBLIT); for (RefModel ref : refs) { if (ref.reference.getName().equals(GB_ISSUES)) { return ref; } } return null; } /** * 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 getIssues(Repository repository, IssueFilter filter) { List list = new ArrayList(); RefModel issuesBranch = getIssuesBranch(repository); if (issuesBranch == null) { return list; } // Collect the set of all issue paths Set issuePaths = new HashSet(); 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 revlog = rw.iterator(); List commits = new ArrayList(); 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, this seems // slightly faster than deserializing each change by itself. Collection changes = JsonUtils.fromJsonString(sb.toString(), new TypeToken>() { }.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 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, this seems // slightly faster than deserializing each change by itself. Collection changes = JsonUtils.fromJsonString(sb.toString(), new TypeToken>() { }.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 changes, boolean effective) { IssueModel issue; if (effective) { List effectiveChanges = new ArrayList(); Map comments = new HashMap(); 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, false); 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 issueId * @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 + " 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 issueId * @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 ignorePaths = new TreeSet(); 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; } }