Paul Martin
2016-04-27 c2188a840bc4153ae92112b04b2e06a90d3944aa
Ticket Reference handling #1048

+ Supports referencing:
+ Tickets from other tickets via comments
+ Tickets from commits on any branch
+ Common TicketLink class used for both commits and tickets
+ TicketLink is temporary and persisted to ticket as a Reference
+ Support deletion of ticket references
+ Rebasing patchsets/branches will generate new references
+ Deleting old patchsets/branches will remove the relevant references
+ Substantial testing of use cases
+ With and without patchsets, deleting, amending
+ BranchTicketService used during testing to allow end-to-end ref testing
+ Relocated common git helper functions to JGitUtils
1 files added
13 files modified
2097 ■■■■■ changed files
src/main/distrib/data/defaults.properties 7 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/git/GitblitReceivePack.java 243 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/git/PatchsetReceivePack.java 331 ●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/models/TicketModel.java 218 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/tickets/ITicketService.java 107 ●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/tickets/TicketNotifier.java 13 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/utils/JGitUtils.java 171 ●●●●● patch | view | raw | blame | history
src/main/java/com/gitblit/wicket/GitBlitWebApp.properties 4 ●●● patch | view | raw | blame | history
src/main/java/com/gitblit/wicket/pages/TicketPage.html 2 ●●● patch | view | raw | blame | history
src/main/java/com/gitblit/wicket/pages/TicketPage.java 43 ●●●● patch | view | raw | blame | history
src/main/resources/gitblit.css 15 ●●●●● patch | view | raw | blame | history
src/test/config/test-gitblit.properties 2 ●●●●● patch | view | raw | blame | history
src/test/java/com/gitblit/tests/GitBlitSuite.java 2 ●●● patch | view | raw | blame | history
src/test/java/com/gitblit/tests/TicketReferenceTest.java 939 ●●●●● patch | view | raw | blame | history
src/main/distrib/data/defaults.properties
@@ -574,6 +574,13 @@
# SINCE 1.5.0
tickets.closeOnPushCommitMessageRegex = (?:fixes|closes)[\\s-]+#?(\\d+)
# The case-insensitive regular expression used to identify and link tickets on
# push to the commits based on commit message.  In the case of a patchset
# self references are ignored
#
# SINCE 1.8.0
tickets.linkOnPushCommitMessageRegex = (?:ref|task|issue|bug)?[\\s-]*#(\\d+)
# Specify the location of the Lucene Ticket index
#
# SINCE 1.4.0
src/main/java/com/gitblit/git/GitblitReceivePack.java
@@ -22,18 +22,28 @@
import java.io.File;
import java.io.IOException;
import java.text.MessageFormat;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.SortedMap;
import java.util.TreeMap;
import java.util.concurrent.TimeUnit;
import org.eclipse.jgit.lib.AnyObjectId;
import org.eclipse.jgit.lib.BatchRefUpdate;
import org.eclipse.jgit.lib.NullProgressMonitor;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.lib.ProgressMonitor;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.RefUpdate;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.transport.PostReceiveHook;
import org.eclipse.jgit.transport.PreReceiveHook;
import org.eclipse.jgit.transport.ReceiveCommand;
@@ -50,14 +60,24 @@
import com.gitblit.extensions.ReceiveHook;
import com.gitblit.manager.IGitblit;
import com.gitblit.models.RepositoryModel;
import com.gitblit.models.TicketModel;
import com.gitblit.models.UserModel;
import com.gitblit.models.TicketModel.Change;
import com.gitblit.models.TicketModel.Field;
import com.gitblit.models.TicketModel.Patchset;
import com.gitblit.models.TicketModel.Status;
import com.gitblit.models.TicketModel.TicketAction;
import com.gitblit.models.TicketModel.TicketLink;
import com.gitblit.tickets.BranchTicketService;
import com.gitblit.tickets.ITicketService;
import com.gitblit.tickets.TicketNotifier;
import com.gitblit.utils.ArrayUtils;
import com.gitblit.utils.ClientLogger;
import com.gitblit.utils.CommitCache;
import com.gitblit.utils.JGitUtils;
import com.gitblit.utils.RefLogUtils;
import com.gitblit.utils.StringUtils;
import com.google.common.collect.Lists;
/**
@@ -92,6 +112,11 @@
    protected final IStoredSettings settings;
    protected final IGitblit gitblit;
    protected final ITicketService ticketService;
    protected final TicketNotifier ticketNotifier;
    public GitblitReceivePack(
            IGitblit gitblit,
@@ -114,6 +139,14 @@
        } catch (IOException e) {
        }
        if (gitblit.getTicketService().isAcceptingTicketUpdates(repository)) {
            this.ticketService = gitblit.getTicketService();
            this.ticketNotifier = this.ticketService.createNotifier();
        } else {
            this.ticketService = null;
            this.ticketNotifier = null;
        }
        // set advanced ref permissions
        setAllowCreates(user.canCreateRef(repository));
        setAllowDeletes(user.canDeleteRef(repository));
@@ -500,6 +533,104 @@
                }
            }
        }
        //
        // if there are ref update receive commands that were
        // successfully processed and there is an active ticket service for the repository
        // then process any referenced tickets
        //
        if (ticketService != null) {
            List<ReceiveCommand> allUpdates = ReceiveCommand.filter(batch.getCommands(), Result.OK);
            if (!allUpdates.isEmpty()) {
                int ticketsProcessed = 0;
                for (ReceiveCommand cmd : allUpdates) {
                    switch (cmd.getType()) {
                    case CREATE:
                    case UPDATE:
                        if (cmd.getRefName().startsWith(Constants.R_HEADS)) {
                            Collection<TicketModel> tickets = processReferencedTickets(cmd);
                            ticketsProcessed += tickets.size();
                            for (TicketModel ticket : tickets) {
                                ticketNotifier.queueMailing(ticket);
                            }
                        }
                        break;
                    case UPDATE_NONFASTFORWARD:
                        if (cmd.getRefName().startsWith(Constants.R_HEADS)) {
                            String base = JGitUtils.getMergeBase(getRepository(), cmd.getOldId(), cmd.getNewId());
                            List<TicketLink> deletedRefs = JGitUtils.identifyTicketsBetweenCommits(getRepository(), settings, base, cmd.getOldId().name());
                            for (TicketLink link : deletedRefs) {
                                link.isDelete = true;
                            }
                            Change deletion = new Change(user.username);
                            deletion.pendingLinks = deletedRefs;
                            ticketService.updateTicket(repository, 0, deletion);
                            Collection<TicketModel> tickets = processReferencedTickets(cmd);
                            ticketsProcessed += tickets.size();
                            for (TicketModel ticket : tickets) {
                                ticketNotifier.queueMailing(ticket);
                            }
                        }
                        break;
                    case DELETE:
                        //Identify if the branch has been merged
                        SortedMap<Integer, String> bases =  new TreeMap<Integer, String>();
                        try {
                            ObjectId dObj = cmd.getOldId();
                            Collection<Ref> tips = getRepository().getRefDatabase().getRefs(Constants.R_HEADS).values();
                            for (Ref ref : tips) {
                                ObjectId iObj = ref.getObjectId();
                                String mergeBase = JGitUtils.getMergeBase(getRepository(), dObj, iObj);
                                if (mergeBase != null) {
                                    int d = JGitUtils.countCommits(getRepository(), getRevWalk(), mergeBase, dObj.name());
                                    bases.put(d, mergeBase);
                                    //All commits have been merged into some other branch
                                    if (d == 0) {
                                        break;
                                    }
                                }
                            }
                            if (bases.isEmpty()) {
                                //TODO: Handle orphan branch case
                            } else {
                                if (bases.firstKey() > 0) {
                                    //Delete references from the remaining commits that haven't been merged
                                    String mergeBase = bases.get(bases.firstKey());
                                    List<TicketLink> deletedRefs = JGitUtils.identifyTicketsBetweenCommits(getRepository(),
                                            settings, mergeBase, dObj.name());
                                    for (TicketLink link : deletedRefs) {
                                        link.isDelete = true;
                                    }
                                    Change deletion = new Change(user.username);
                                    deletion.pendingLinks = deletedRefs;
                                    ticketService.updateTicket(repository, 0, deletion);
                                }
                            }
                        } catch (IOException e) {
                            LOGGER.error(null, e);
                        }
                        break;
                    default:
                        break;
                    }
                }
                if (ticketsProcessed == 1) {
                    sendInfo("1 ticket updated");
                } else if (ticketsProcessed > 1) {
                    sendInfo("{0} tickets updated", ticketsProcessed);
                }
            }
            // reset the ticket caches for the repository
            ticketService.resetCaches(repository);
        }
    }
    protected void setGitblitUrl(String url) {
@@ -616,4 +747,116 @@
    public UserModel getUserModel() {
        return user;
    }
    /**
     * Automatically closes open tickets and adds references to tickets if made in the commit message.
     *
     * @param cmd
     */
    private Collection<TicketModel> processReferencedTickets(ReceiveCommand cmd) {
        Map<Long, TicketModel> changedTickets = new LinkedHashMap<Long, TicketModel>();
        final RevWalk rw = getRevWalk();
        try {
            rw.reset();
            rw.markStart(rw.parseCommit(cmd.getNewId()));
            if (!ObjectId.zeroId().equals(cmd.getOldId())) {
                rw.markUninteresting(rw.parseCommit(cmd.getOldId()));
            }
            RevCommit c;
            while ((c = rw.next()) != null) {
                rw.parseBody(c);
                List<TicketLink> ticketLinks = JGitUtils.identifyTicketsFromCommitMessage(getRepository(), settings, c);
                if (ticketLinks == null) {
                    continue;
                }
                for (TicketLink link : ticketLinks) {
                    TicketModel ticket = ticketService.getTicket(repository, link.targetTicketId);
                    if (ticket == null) {
                        continue;
                    }
                    Change change = null;
                    String commitSha = c.getName();
                    String branchName = Repository.shortenRefName(cmd.getRefName());
                    switch (link.action) {
                        case Commit: {
                            //A commit can reference a ticket in any branch even if the ticket is closed.
                            //This allows developers to identify and communicate related issues
                            change = new Change(user.username);
                            change.referenceCommit(commitSha);
                        } break;
                        case Close: {
                            // As this isn't a patchset theres no merging taking place when closing a ticket
                            if (ticket.isClosed()) {
                                continue;
                            }
                            change = new Change(user.username);
                            change.setField(Field.status, Status.Fixed);
                            if (StringUtils.isEmpty(ticket.responsible)) {
                                // unassigned tickets are assigned to the closer
                                change.setField(Field.responsible, user.username);
                            }
                        }
                        default: {
                            //No action
                        } break;
                    }
                    if (change != null) {
                        ticket = ticketService.updateTicket(repository, ticket.number, change);
                    }
                    if (ticket != null) {
                        sendInfo("");
                        sendHeader("#{0,number,0}: {1}", ticket.number, StringUtils.trimString(ticket.title, Constants.LEN_SHORTLOG));
                        switch (link.action) {
                            case Commit: {
                                sendInfo("referenced by push of {0} to {1}", commitSha, branchName);
                                changedTickets.put(ticket.number, ticket);
                            } break;
                            case Close: {
                                sendInfo("closed by push of {0} to {1}", commitSha, branchName);
                                changedTickets.put(ticket.number, ticket);
                            } break;
                            default: { }
                        }
                        sendInfo(ticketService.getTicketUrl(ticket));
                        sendInfo("");
                    } else {
                        switch (link.action) {
                            case Commit: {
                                sendError("FAILED to reference ticket {0} by push of {1}", link.targetTicketId, commitSha);
                            } break;
                            case Close: {
                                sendError("FAILED to close ticket {0} by push of {1}", link.targetTicketId, commitSha);
                            } break;
                            default: { }
                        }
                    }
                }
            }
        } catch (IOException e) {
            LOGGER.error("Can't scan for changes to reference or close", e);
        } finally {
            rw.reset();
        }
        return changedTickets.values();
    }
}
src/main/java/com/gitblit/git/PatchsetReceivePack.java
@@ -30,7 +30,6 @@
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.eclipse.jgit.lib.AnyObjectId;
import org.eclipse.jgit.lib.BatchRefUpdate;
import org.eclipse.jgit.lib.NullProgressMonitor;
import org.eclipse.jgit.lib.ObjectId;
@@ -60,6 +59,8 @@
import com.gitblit.models.TicketModel.Patchset;
import com.gitblit.models.TicketModel.PatchsetType;
import com.gitblit.models.TicketModel.Status;
import com.gitblit.models.TicketModel.TicketAction;
import com.gitblit.models.TicketModel.TicketLink;
import com.gitblit.models.UserModel;
import com.gitblit.tickets.BranchTicketService;
import com.gitblit.tickets.ITicketService;
@@ -485,9 +486,27 @@
                switch (cmd.getType()) {
                case CREATE:
                case UPDATE:
                    if (cmd.getRefName().startsWith(Constants.R_HEADS)) {
                        Collection<TicketModel> tickets = processReferencedTickets(cmd);
                        ticketsProcessed += tickets.size();
                        for (TicketModel ticket : tickets) {
                            ticketNotifier.queueMailing(ticket);
                        }
                    }
                    break;
                case UPDATE_NONFASTFORWARD:
                    if (cmd.getRefName().startsWith(Constants.R_HEADS)) {
                        Collection<TicketModel> tickets = processMergedTickets(cmd);
                        String base = JGitUtils.getMergeBase(getRepository(), cmd.getOldId(), cmd.getNewId());
                        List<TicketLink> deletedRefs = JGitUtils.identifyTicketsBetweenCommits(getRepository(), settings, base, cmd.getOldId().name());
                        for (TicketLink link : deletedRefs) {
                            link.isDelete = true;
                        }
                        Change deletion = new Change(user.username);
                        deletion.pendingLinks = deletedRefs;
                        ticketService.updateTicket(repository, 0, deletion);
                        Collection<TicketModel> tickets = processReferencedTickets(cmd);
                        ticketsProcessed += tickets.size();
                        for (TicketModel ticket : tickets) {
                            ticketNotifier.queueMailing(ticket);
@@ -604,15 +623,17 @@
                return null;
            }
        }
        // check to see if this commit is already linked to a ticket
        long id = identifyTicket(tipCommit, false);
        if (id > 0) {
            sendError("{0} has already been pushed to ticket {1,number,0}.", shortTipId, id);
        if (ticket != null &&
                JGitUtils.getTicketNumberFromCommitBranch(getRepository(), tipCommit) == ticket.number) {
            sendError("{0} has already been pushed to ticket {1,number,0}.", shortTipId, ticket.number);
            sendRejection(cmd, "everything up-to-date");
            return null;
        }
        List<TicketLink> ticketLinks = JGitUtils.identifyTicketsFromCommitMessage(getRepository(), settings, tipCommit);
        PatchsetCommand psCmd;
        if (ticket == null) {
            /*
@@ -802,6 +823,10 @@
            }
            break;
        }
        Change change = psCmd.getChange();
        change.pendingLinks = ticketLinks;
        return psCmd;
    }
@@ -890,11 +915,11 @@
    /**
     * Automatically closes open tickets that have been merged to their integration
     * branch by a client.
     * branch by a client and adds references to tickets if made in the commit message.
     *
     * @param cmd
     */
    private Collection<TicketModel> processMergedTickets(ReceiveCommand cmd) {
    private Collection<TicketModel> processReferencedTickets(ReceiveCommand cmd) {
        Map<Long, TicketModel> mergedTickets = new LinkedHashMap<Long, TicketModel>();
        final RevWalk rw = getRevWalk();
        try {
@@ -907,105 +932,151 @@
            RevCommit c;
            while ((c = rw.next()) != null) {
                rw.parseBody(c);
                long ticketNumber = identifyTicket(c, true);
                if (ticketNumber == 0L || mergedTickets.containsKey(ticketNumber)) {
                List<TicketLink> ticketLinks = JGitUtils.identifyTicketsFromCommitMessage(getRepository(), settings, c);
                if (ticketLinks == null) {
                    continue;
                }
                TicketModel ticket = ticketService.getTicket(repository, ticketNumber);
                if (ticket == null) {
                    continue;
                }
                String integrationBranch;
                if (StringUtils.isEmpty(ticket.mergeTo)) {
                    // unspecified integration branch
                    integrationBranch = null;
                } else {
                    // specified integration branch
                    integrationBranch = Constants.R_HEADS + ticket.mergeTo;
                }
                // ticket must be open and, if specified, the ref must match the integration branch
                if (ticket.isClosed() || (integrationBranch != null && !integrationBranch.equals(cmd.getRefName()))) {
                    continue;
                }
                String baseRef = PatchsetCommand.getBasePatchsetBranch(ticket.number);
                boolean knownPatchset = false;
                Set<Ref> refs = getRepository().getAllRefsByPeeledObjectId().get(c.getId());
                if (refs != null) {
                    for (Ref ref : refs) {
                        if (ref.getName().startsWith(baseRef)) {
                            knownPatchset = true;
                            break;
                        }
                    }
                }
                String mergeSha = c.getName();
                String mergeTo = Repository.shortenRefName(cmd.getRefName());
                Change change;
                Patchset patchset;
                if (knownPatchset) {
                    // identify merged patchset by the patchset tip
                    patchset = null;
                    for (Patchset ps : ticket.getPatchsets()) {
                        if (ps.tip.equals(mergeSha)) {
                            patchset = ps;
                            break;
                        }
                    }
                    if (patchset == null) {
                        // should not happen - unless ticket has been hacked
                        sendError("Failed to find the patchset for {0} in ticket {1,number,0}?!",
                                mergeSha, ticket.number);
                for (TicketLink link : ticketLinks) {
                    if (mergedTickets.containsKey(link.targetTicketId)) {
                        continue;
                    }
                    TicketModel ticket = ticketService.getTicket(repository, link.targetTicketId);
                    if (ticket == null) {
                        continue;
                    }
                    String integrationBranch;
                    if (StringUtils.isEmpty(ticket.mergeTo)) {
                        // unspecified integration branch
                        integrationBranch = null;
                    } else {
                        // specified integration branch
                        integrationBranch = Constants.R_HEADS + ticket.mergeTo;
                    }
                    Change change;
                    Patchset patchset = null;
                    String mergeSha = c.getName();
                    String mergeTo = Repository.shortenRefName(cmd.getRefName());
                    // create a new change
                    change = new Change(user.username);
                } else {
                    // new patchset pushed by user
                    String base = cmd.getOldId().getName();
                    patchset = newPatchset(ticket, base, mergeSha);
                    PatchsetCommand psCmd = new PatchsetCommand(user.username, patchset);
                    psCmd.updateTicket(c, mergeTo, ticket, null);
                    if (link.action == TicketAction.Commit) {
                        //A commit can reference a ticket in any branch even if the ticket is closed.
                        //This allows developers to identify and communicate related issues
                        change = new Change(user.username);
                        change.referenceCommit(mergeSha);
                    } else {
                        // ticket must be open and, if specified, the ref must match the integration branch
                        if (ticket.isClosed() || (integrationBranch != null && !integrationBranch.equals(cmd.getRefName()))) {
                            continue;
                        }
                        String baseRef = PatchsetCommand.getBasePatchsetBranch(ticket.number);
                        boolean knownPatchset = false;
                        Set<Ref> refs = getRepository().getAllRefsByPeeledObjectId().get(c.getId());
                        if (refs != null) {
                            for (Ref ref : refs) {
                                if (ref.getName().startsWith(baseRef)) {
                                    knownPatchset = true;
                                    break;
                                }
                            }
                        }
                        if (knownPatchset) {
                            // identify merged patchset by the patchset tip
                            for (Patchset ps : ticket.getPatchsets()) {
                                if (ps.tip.equals(mergeSha)) {
                                    patchset = ps;
                                    break;
                                }
                            }
                            if (patchset == null) {
                                // should not happen - unless ticket has been hacked
                                sendError("Failed to find the patchset for {0} in ticket {1,number,0}?!",
                                        mergeSha, ticket.number);
                                continue;
                            }
                            // create a new change
                            change = new Change(user.username);
                        } else {
                            // new patchset pushed by user
                            String base = cmd.getOldId().getName();
                            patchset = newPatchset(ticket, base, mergeSha);
                            PatchsetCommand psCmd = new PatchsetCommand(user.username, patchset);
                            psCmd.updateTicket(c, mergeTo, ticket, null);
                            // create a ticket patchset ref
                            updateRef(psCmd.getPatchsetBranch(), c.getId(), patchset.type);
                            RefUpdate ru = updateRef(psCmd.getTicketBranch(), c.getId(), patchset.type);
                            updateReflog(ru);
                            // create a change from the patchset command
                            change = psCmd.getChange();
                        }
                        // set the common change data about the merge
                        change.setField(Field.status, Status.Merged);
                        change.setField(Field.mergeSha, mergeSha);
                        change.setField(Field.mergeTo, mergeTo);
                        if (StringUtils.isEmpty(ticket.responsible)) {
                            // unassigned tickets are assigned to the closer
                            change.setField(Field.responsible, user.username);
                        }
                    }
                    ticket = ticketService.updateTicket(repository, ticket.number, change);
                    if (ticket != null) {
                        sendInfo("");
                        sendHeader("#{0,number,0}: {1}", ticket.number, StringUtils.trimString(ticket.title, Constants.LEN_SHORTLOG));
                    // create a ticket patchset ref
                    updateRef(psCmd.getPatchsetBranch(), c.getId(), patchset.type);
                    RefUpdate ru = updateRef(psCmd.getTicketBranch(), c.getId(), patchset.type);
                    updateReflog(ru);
                        switch (link.action) {
                            case Commit: {
                                sendInfo("referenced by push of {0} to {1}", c.getName(), mergeTo);
                            }
                            break;
                    // create a change from the patchset command
                    change = psCmd.getChange();
                }
                            case Close: {
                                sendInfo("closed by push of {0} to {1}", patchset, mergeTo);
                                mergedTickets.put(ticket.number, ticket);
                            }
                            break;
                // set the common change data about the merge
                change.setField(Field.status, Status.Merged);
                change.setField(Field.mergeSha, mergeSha);
                change.setField(Field.mergeTo, mergeTo);
                            default: {
                            }
                        }
                if (StringUtils.isEmpty(ticket.responsible)) {
                    // unassigned tickets are assigned to the closer
                    change.setField(Field.responsible, user.username);
                }
                        sendInfo(ticketService.getTicketUrl(ticket));
                        sendInfo("");
                ticket = ticketService.updateTicket(repository, ticket.number, change);
                if (ticket != null) {
                    sendInfo("");
                    sendHeader("#{0,number,0}: {1}", ticket.number, StringUtils.trimString(ticket.title, Constants.LEN_SHORTLOG));
                    sendInfo("closed by push of {0} to {1}", patchset, mergeTo);
                    sendInfo(ticketService.getTicketUrl(ticket));
                    sendInfo("");
                    mergedTickets.put(ticket.number, ticket);
                } else {
                    String shortid = mergeSha.substring(0, settings.getInteger(Keys.web.shortCommitIdLength, 6));
                    sendError("FAILED to close ticket {0,number,0} by push of {1}", ticketNumber, shortid);
                    } else {
                        String shortid = mergeSha.substring(0, settings.getInteger(Keys.web.shortCommitIdLength, 6));
                        switch (link.action) {
                            case Commit: {
                                sendError("FAILED to reference ticket {0,number,0} by push of {1}", link.targetTicketId, shortid);
                            }
                            break;
                            case Close: {
                                sendError("FAILED to close ticket {0,number,0} by push of {1}", link.targetTicketId, shortid);
                            } break;
                            default: {
                            }
                        }
                    }
                }
            }
        } catch (IOException e) {
            LOGGER.error("Can't scan for changes to close", e);
            LOGGER.error("Can't scan for changes to reference or close", e);
        } finally {
            rw.reset();
        }
@@ -1013,75 +1084,9 @@
        return mergedTickets.values();
    }
    /**
     * Try to identify a ticket id from the commit.
     *
     * @param commit
     * @param parseMessage
     * @return a ticket id or 0
     */
    private long identifyTicket(RevCommit commit, boolean parseMessage) {
        // try lookup by change ref
        Map<AnyObjectId, Set<Ref>> map = getRepository().getAllRefsByPeeledObjectId();
        Set<Ref> refs = map.get(commit.getId());
        if (!ArrayUtils.isEmpty(refs)) {
            for (Ref ref : refs) {
                long number = PatchsetCommand.getTicketNumber(ref.getName());
                if (number > 0) {
                    return number;
                }
            }
        }
        if (parseMessage) {
            // parse commit message looking for fixes/closes #n
            String dx = "(?:fixes|closes)[\\s-]+#?(\\d+)";
            String x = settings.getString(Keys.tickets.closeOnPushCommitMessageRegex, dx);
            if (StringUtils.isEmpty(x)) {
                x = dx;
            }
            try {
                Pattern p = Pattern.compile(x, Pattern.CASE_INSENSITIVE);
                Matcher m = p.matcher(commit.getFullMessage());
                while (m.find()) {
                    String val = m.group(1);
                    return Long.parseLong(val);
                }
            } catch (Exception e) {
                LOGGER.error(String.format("Failed to parse \"%s\" in commit %s", x, commit.getName()), e);
            }
        }
        return 0L;
    }
    private int countCommits(String baseId, String tipId) {
        int count = 0;
        RevWalk walk = getRevWalk();
        walk.reset();
        walk.sort(RevSort.TOPO);
        walk.sort(RevSort.REVERSE, true);
        try {
            RevCommit tip = walk.parseCommit(getRepository().resolve(tipId));
            RevCommit base = walk.parseCommit(getRepository().resolve(baseId));
            walk.markStart(tip);
            walk.markUninteresting(base);
            for (;;) {
                RevCommit c = walk.next();
                if (c == null) {
                    break;
                }
                count++;
            }
        } catch (IOException e) {
            // Should never happen, the core receive process would have
            // identified the missing object earlier before we got control.
            LOGGER.error("failed to get commit count", e);
            return 0;
        } finally {
            walk.close();
        }
        return count;
    }
    /**
     * Creates a new patchset with metadata.
@@ -1091,7 +1096,7 @@
     * @param tip
     */
    private Patchset newPatchset(TicketModel ticket, String mergeBase, String tip) {
        int totalCommits = countCommits(mergeBase, tip);
        int totalCommits = JGitUtils.countCommits(getRepository(), getRevWalk(), mergeBase, tip);
        Patchset newPatchset = new Patchset();
        newPatchset.tip = tip;
src/main/java/com/gitblit/models/TicketModel.java
@@ -107,6 +107,7 @@
        TicketModel ticket;
        List<Change> effectiveChanges = new ArrayList<Change>();
        Map<String, Change> comments = new HashMap<String, Change>();
        Map<String, Change> references = new HashMap<String, Change>();
        Map<Integer, Integer> latestRevisions = new HashMap<Integer, Integer>();
        
        int latestPatchsetNumber = -1;
@@ -159,6 +160,18 @@
                    
                    effectiveChanges.add(change);
                }
            } else if (change.reference != null){
                if (references.containsKey(change.reference.toString())) {
                    Change original = references.get(change.reference.toString());
                    Change clone = copy(original);
                    clone.reference.deleted = change.reference.deleted;
                    int idx = effectiveChanges.indexOf(original);
                    effectiveChanges.remove(original);
                    effectiveChanges.add(idx, clone);
                } else {
                    effectiveChanges.add(change);
                    references.put(change.reference.toString(), change);
                }
            } else {
                effectiveChanges.add(change);
            }
@@ -167,9 +180,15 @@
        // effective ticket
        ticket = new TicketModel();
        for (Change change : effectiveChanges) {
            //Ensure deleted items are not included
            if (!change.hasComment()) {
                // ensure we do not include a deleted comment
                change.comment = null;
            }
            if (!change.hasReference()) {
                change.reference = null;
            }
            if (!change.hasPatchset()) {
                change.patchset = null;
            }
            ticket.applyChange(change);
        }
@@ -354,6 +373,15 @@
        return false;
    }
    public boolean hasReferences() {
        for (Change change : changes) {
            if (change.hasReference()) {
                return true;
            }
        }
        return false;
    }
    public List<Attachment> getAttachments() {
        List<Attachment> list = new ArrayList<Attachment>();
        for (Change change : changes) {
@@ -364,6 +392,16 @@
        return list;
    }
    public List<Reference> getReferences() {
        List<Reference> list = new ArrayList<Reference>();
        for (Change change : changes) {
            if (change.hasReference()) {
                list.add(change.reference);
            }
        }
        return list;
    }
    public List<Patchset> getPatchsets() {
        List<Patchset> list = new ArrayList<Patchset>();
        for (Change change : changes) {
@@ -573,8 +611,12 @@
            }
        }
        // add the change to the ticket
        changes.add(change);
        // add real changes to the ticket and ensure deleted changes are removed
        if (change.isEmptyChange()) {
            changes.remove(change);
        } else {
            changes.add(change);
        }
    }
    protected String toString(Object value) {
@@ -645,6 +687,8 @@
        public Comment comment;
        public Reference reference;
        public Map<Field, String> fields;
        public Set<Attachment> attachments;
@@ -654,6 +698,10 @@
        public Review review;
        private transient String id;
        //Once links have been made they become a reference on the target ticket
        //The ticket service handles promoting links to references
        public transient List<TicketLink> pendingLinks;
        public Change(String author) {
            this(author, new Date());
@@ -678,7 +726,7 @@
        }
        public boolean hasPatchset() {
            return patchset != null;
            return patchset != null && !patchset.isDeleted();
        }
        public boolean hasReview() {
@@ -688,11 +736,42 @@
        public boolean hasComment() {
            return comment != null && !comment.isDeleted() && comment.text != null;
        }
        public boolean hasReference() {
            return reference != null && !reference.isDeleted();
        }
        public boolean hasPendingLinks() {
            return pendingLinks != null && pendingLinks.size() > 0;
        }
        public Comment comment(String text) {
            comment = new Comment(text);
            comment.id = TicketModel.getSHA1(date.toString() + author + text);
            // parse comment looking for ref #n
            //TODO: Ideally set via settings
            String x = "(?:ref|task|issue|bug)?[\\s-]*#(\\d+)";
            try {
                Pattern p = Pattern.compile(x, Pattern.CASE_INSENSITIVE);
                Matcher m = p.matcher(text);
                while (m.find()) {
                    String val = m.group(1);
                    long targetTicketId = Long.parseLong(val);
                    if (targetTicketId > 0) {
                        if (pendingLinks == null) {
                            pendingLinks = new ArrayList<TicketLink>();
                        }
                        pendingLinks.add(new TicketLink(targetTicketId, TicketAction.Comment));
                    }
                }
            } catch (Exception e) {
                // ignore
            }
            try {
                Pattern mentions = Pattern.compile("\\s@([A-Za-z0-9-_]+)");
                Matcher m = mentions.matcher(text);
@@ -706,6 +785,16 @@
            return comment;
        }
        public Reference referenceCommit(String commitHash) {
            reference = new Reference(commitHash);
            return reference;
        }
        public Reference referenceTicket(long ticketId, String changeHash) {
            reference = new Reference(ticketId, changeHash);
            return reference;
        }
        public Review review(Patchset patchset, Score score, boolean addReviewer) {
            if (addReviewer) {
                plusList(Field.reviewers, author);
@@ -876,6 +965,17 @@
            }
            return false;
        }
        /*
         * Identify if this is an empty change. i.e. only an author and date is defined.
         * This can occur when items have been deleted
         * @returns true if the change is empty
         */
        private boolean isEmptyChange() {
            return ((comment == null) && (reference == null) &&
                    (fields == null) && (attachments == null) &&
                    (patchset == null) && (review == null));
        }
        @Override
        public String toString() {
@@ -885,6 +985,8 @@
                sb.append(" commented on by ");
            } else if (hasPatchset()) {
                sb.append(MessageFormat.format(" {0} uploaded by ", patchset));
            } else if (hasReference()) {
                sb.append(MessageFormat.format(" referenced in {0} by ", reference));
            } else {
                sb.append(" changed by ");
            }
@@ -1145,6 +1247,114 @@
            return text;
        }
    }
    public static enum TicketAction {
        Commit, Comment, Patchset, Close
    }
    //Intentionally not serialized, links are persisted as "references"
    public static class TicketLink {
        public long targetTicketId;
        public String hash;
        public TicketAction action;
        public boolean success;
        public boolean isDelete;
        public TicketLink(long targetTicketId, TicketAction action) {
            this.targetTicketId = targetTicketId;
            this.action = action;
            success = false;
            isDelete = false;
        }
        public TicketLink(long targetTicketId, TicketAction action, String hash) {
            this.targetTicketId = targetTicketId;
            this.action = action;
            this.hash = hash;
            success = false;
            isDelete = false;
        }
    }
    public static enum ReferenceType {
        Undefined, Commit, Ticket;
        @Override
        public String toString() {
            return name().toLowerCase().replace('_', ' ');
        }
        public static ReferenceType fromObject(Object o, ReferenceType defaultType) {
            if (o instanceof ReferenceType) {
                // cast and return
                return (ReferenceType) o;
            } else if (o instanceof String) {
                // find by name
                for (ReferenceType type : values()) {
                    String str = o.toString();
                    if (type.name().equalsIgnoreCase(str)
                            || 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 defaultType;
        }
    }
    public static class Reference implements Serializable {
        private static final long serialVersionUID = 1L;
        public String hash;
        public Long ticketId;
        public Boolean deleted;
        Reference(String commitHash) {
            this.hash = commitHash;
        }
        Reference(long ticketId, String changeHash) {
            this.ticketId = ticketId;
            this.hash = changeHash;
        }
        public ReferenceType getSourceType(){
            if (hash != null) {
                if (ticketId != null) {
                    return ReferenceType.Ticket;
                } else {
                    return ReferenceType.Commit;
                }
            }
            return ReferenceType.Undefined;
        }
        public boolean isDeleted() {
            return deleted != null && deleted;
        }
        @Override
        public String toString() {
            switch (getSourceType()) {
                case Commit: return hash;
                case Ticket: return ticketId.toString() + "#" + hash;
                default: {} break;
            }
            return String.format("Unknown Reference Type");
        }
    }
    public static class Attachment implements Serializable {
src/main/java/com/gitblit/tickets/ITicketService.java
@@ -50,9 +50,11 @@
import com.gitblit.models.TicketModel.Patchset;
import com.gitblit.models.TicketModel.PatchsetType;
import com.gitblit.models.TicketModel.Status;
import com.gitblit.models.TicketModel.TicketLink;
import com.gitblit.tickets.TicketIndexer.Lucene;
import com.gitblit.utils.DeepCopier;
import com.gitblit.utils.DiffUtils;
import com.gitblit.utils.JGitUtils;
import com.gitblit.utils.DiffUtils.DiffStat;
import com.gitblit.utils.StringUtils;
import com.google.common.cache.Cache;
@@ -1021,12 +1023,12 @@
    }
    /**
     * Updates a ticket.
     * Updates a ticket and promotes pending links into references.
     *
     * @param repository
     * @param ticketId
     * @param ticketId, or 0 to action pending links in general
     * @param change
     * @return the ticket model if successful
     * @return the ticket model if successful, null if failure or using 0 ticketId
     * @since 1.4.0
     */
    public final TicketModel updateTicket(RepositoryModel repository, long ticketId, Change change) {
@@ -1038,28 +1040,78 @@
            throw new RuntimeException("must specify a change author!");
        }
        TicketKey key = new TicketKey(repository, ticketId);
        ticketsCache.invalidate(key);
        boolean success = commitChangeImpl(repository, ticketId, change);
        if (success) {
            TicketModel ticket = getTicket(repository, ticketId);
            ticketsCache.put(key, ticket);
            indexer.index(ticket);
            // call the ticket hooks
            if (pluginManager != null) {
                for (TicketHook hook : pluginManager.getExtensions(TicketHook.class)) {
                    try {
                        hook.onUpdateTicket(ticket, change);
                    } catch (Exception e) {
                        log.error("Failed to execute extension", e);
        boolean success = true;
        TicketModel ticket = null;
        if (ticketId > 0) {
            TicketKey key = new TicketKey(repository, ticketId);
            ticketsCache.invalidate(key);
            success = commitChangeImpl(repository, ticketId, change);
            if (success) {
                ticket = getTicket(repository, ticketId);
                ticketsCache.put(key, ticket);
                indexer.index(ticket);
                // call the ticket hooks
                if (pluginManager != null) {
                    for (TicketHook hook : pluginManager.getExtensions(TicketHook.class)) {
                        try {
                            hook.onUpdateTicket(ticket, change);
                        } catch (Exception e) {
                            log.error("Failed to execute extension", e);
                        }
                    }
                }
            }
            return ticket;
        }
        return null;
        if (success) {
            //Now that the ticket has been successfully persisted add references to this ticket from linked tickets
            if (change.hasPendingLinks()) {
                for (TicketLink link : change.pendingLinks) {
                    TicketModel linkedTicket = getTicket(repository, link.targetTicketId);
                    Change dstChange = null;
                    //Ignore if not available or self reference
                    if (linkedTicket != null && link.targetTicketId != ticketId) {
                        dstChange = new Change(change.author, change.date);
                        switch (link.action) {
                            case Comment: {
                                if (ticketId == 0) {
                                    throw new RuntimeException("must specify a ticket when linking a comment!");
                                }
                                dstChange.referenceTicket(ticketId, change.comment.id);
                            } break;
                            case Commit: {
                                dstChange.referenceCommit(link.hash);
                            } break;
                            default: {
                                throw new RuntimeException(
                                        String.format("must add persist logic for link of type %s", link.action));
                            }
                        }
                    }
                    if (dstChange != null) {
                        //If not deleted then remain null in journal
                        if (link.isDelete) {
                            dstChange.reference.deleted = true;
                        }
                        if (updateTicket(repository, link.targetTicketId, dstChange) != null) {
                            link.success = true;
                        }
                    }
                }
            }
        }
        return ticket;
    }
    /**
@@ -1232,9 +1284,18 @@
        deletion.patchset.number = patchset.number;
        deletion.patchset.rev = patchset.rev;
        deletion.patchset.type = PatchsetType.Delete;
        //Find and delete references to tickets by the removed commits
        List<TicketLink> patchsetTicketLinks = JGitUtils.identifyTicketsBetweenCommits(
                repositoryManager.getRepository(ticket.repository),
                settings, patchset.base, patchset.tip);
        
        RepositoryModel repository = repositoryManager.getRepositoryModel(ticket.repository);
        TicketModel revisedTicket = updateTicket(repository, ticket.number, deletion);
        for (TicketLink link : patchsetTicketLinks) {
            link.isDelete = true;
        }
        deletion.pendingLinks = patchsetTicketLinks;
        RepositoryModel repositoryModel = repositoryManager.getRepositoryModel(ticket.repository);
        TicketModel revisedTicket = updateTicket(repositoryModel, ticket.number, deletion);
        
        return revisedTicket;
    } 
src/main/java/com/gitblit/tickets/TicketNotifier.java
@@ -317,6 +317,19 @@
            // comment update
            sb.append(MessageFormat.format("**{0}** commented on this ticket.", user.getDisplayName()));
            sb.append(HARD_BRK);
        } else if (lastChange.hasReference()) {
            // reference update
            String type = "?";
            switch (lastChange.reference.getSourceType()) {
                case Commit: { type = "commit"; } break;
                case Ticket: { type = "ticket"; } break;
                default: { } break;
            }
            sb.append(MessageFormat.format("**{0}** referenced this ticket in {1} {2}", type, lastChange.toString()));
            sb.append(HARD_BRK);
        } else {
            // general update
            pattern = "**{0}** has updated this ticket.";
src/main/java/com/gitblit/utils/JGitUtils.java
@@ -28,6 +28,7 @@
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.Map.Entry;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -46,12 +47,15 @@
import org.eclipse.jgit.diff.RawTextComparator;
import org.eclipse.jgit.dircache.DirCache;
import org.eclipse.jgit.dircache.DirCacheEntry;
import org.eclipse.jgit.errors.AmbiguousObjectException;
import org.eclipse.jgit.errors.ConfigInvalidException;
import org.eclipse.jgit.errors.IncorrectObjectTypeException;
import org.eclipse.jgit.errors.LargeObjectException;
import org.eclipse.jgit.errors.MissingObjectException;
import org.eclipse.jgit.errors.RevisionSyntaxException;
import org.eclipse.jgit.errors.StopWalkException;
import org.eclipse.jgit.internal.JGitText;
import org.eclipse.jgit.lib.AnyObjectId;
import org.eclipse.jgit.lib.BlobBasedConfig;
import org.eclipse.jgit.lib.CommitBuilder;
import org.eclipse.jgit.lib.Constants;
@@ -91,19 +95,22 @@
import org.eclipse.jgit.treewalk.filter.PathSuffixFilter;
import org.eclipse.jgit.treewalk.filter.TreeFilter;
import org.eclipse.jgit.util.FS;
import org.jetbrains.annotations.NotNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.gitblit.GitBlit;
import com.gitblit.GitBlitException;
import com.gitblit.manager.GitblitManager;
import com.gitblit.IStoredSettings;
import com.gitblit.Keys;
import com.gitblit.git.PatchsetCommand;
import com.gitblit.models.FilestoreModel;
import com.gitblit.models.GitNote;
import com.gitblit.models.PathModel;
import com.gitblit.models.PathModel.PathChangeModel;
import com.gitblit.models.TicketModel.TicketAction;
import com.gitblit.models.TicketModel.TicketLink;
import com.gitblit.models.RefModel;
import com.gitblit.models.SubmoduleModel;
import com.gitblit.servlet.FilestoreServlet;
import com.google.common.base.Strings;
/**
@@ -2740,5 +2747,163 @@
        }
        return false;
    }
    /*
     * Identify ticket by considering the branch the commit is on
     *
     * @param repository
     * @param commit
     * @return ticket number, or 0 if no ticket
     */
    public static long getTicketNumberFromCommitBranch(Repository repository, RevCommit commit) {
        // try lookup by change ref
        Map<AnyObjectId, Set<Ref>> map = repository.getAllRefsByPeeledObjectId();
        Set<Ref> refs = map.get(commit.getId());
        if (!ArrayUtils.isEmpty(refs)) {
            for (Ref ref : refs) {
                long number = PatchsetCommand.getTicketNumber(ref.getName());
                if (number > 0) {
                    return number;
                }
            }
        }
        return 0;
    }
    /**
     * Try to identify all referenced tickets from the commit.
     *
     * @param commit
     * @return a collection of TicketLinks
     */
    @NotNull
    public static List<TicketLink> identifyTicketsFromCommitMessage(Repository repository, IStoredSettings settings,
            RevCommit commit) {
        List<TicketLink> ticketLinks = new ArrayList<TicketLink>();
        List<Long> linkedTickets = new ArrayList<Long>();
        // parse commit message looking for fixes/closes #n
        final String xFixDefault = "(?:fixes|closes)[\\s-]+#?(\\d+)";
        String xFix = settings.getString(Keys.tickets.closeOnPushCommitMessageRegex, xFixDefault);
        if (StringUtils.isEmpty(xFix)) {
            xFix = xFixDefault;
        }
        try {
            Pattern p = Pattern.compile(xFix, Pattern.CASE_INSENSITIVE);
            Matcher m = p.matcher(commit.getFullMessage());
            while (m.find()) {
                String val = m.group(1);
                long number = Long.parseLong(val);
                if (number > 0) {
                    ticketLinks.add(new TicketLink(number, TicketAction.Close));
                    linkedTickets.add(number);
                }
            }
        } catch (Exception e) {
            LOGGER.error(String.format("Failed to parse \"%s\" in commit %s", xFix, commit.getName()), e);
        }
        // parse commit message looking for ref #n
        final String xRefDefault = "(?:ref|task|issue|bug)?[\\s-]*#(\\d+)";
        String xRef = settings.getString(Keys.tickets.linkOnPushCommitMessageRegex, xRefDefault);
        if (StringUtils.isEmpty(xRef)) {
            xRef = xRefDefault;
        }
        try {
            Pattern p = Pattern.compile(xRef, Pattern.CASE_INSENSITIVE);
            Matcher m = p.matcher(commit.getFullMessage());
            while (m.find()) {
                String val = m.group(1);
                long number = Long.parseLong(val);
                //Most generic case so don't included tickets more precisely linked
                if ((number > 0) && (!linkedTickets.contains(number))) {
                    ticketLinks.add( new TicketLink(number, TicketAction.Commit, commit.getName()));
                    linkedTickets.add(number);
                }
            }
        } catch (Exception e) {
            LOGGER.error(String.format("Failed to parse \"%s\" in commit %s", xRef, commit.getName()), e);
        }
        return ticketLinks;
    }
    /**
     * Try to identify all referenced tickets between two commits
     *
     * @param commit
     * @param parseMessage
     * @param currentTicketId, or 0 if not on a ticket branch
     * @return a collection of TicketLink, or null if commit is already linked
     */
    public static List<TicketLink> identifyTicketsBetweenCommits(Repository repository, IStoredSettings settings,
            String baseSha, String tipSha) {
        List<TicketLink> links = new ArrayList<TicketLink>();
        if (repository == null) { return links; }
        RevWalk walk = new RevWalk(repository);
        walk.sort(RevSort.TOPO);
        walk.sort(RevSort.REVERSE, true);
        try {
            RevCommit tip = walk.parseCommit(repository.resolve(tipSha));
            RevCommit base = walk.parseCommit(repository.resolve(baseSha));
            walk.markStart(tip);
            walk.markUninteresting(base);
            for (;;) {
                RevCommit commit = walk.next();
                if (commit == null) {
                    break;
                }
                links.addAll(JGitUtils.identifyTicketsFromCommitMessage(repository, settings, commit));
            }
        } catch (IOException e) {
            LOGGER.error("failed to identify tickets between commits.", e);
        } finally {
            walk.dispose();
        }
        return links;
    }
    public static int countCommits(Repository repository, RevWalk walk, ObjectId baseId, ObjectId tipId) {
        int count = 0;
        walk.reset();
        walk.sort(RevSort.TOPO);
        walk.sort(RevSort.REVERSE, true);
        try {
            RevCommit tip = walk.parseCommit(tipId);
            RevCommit base = walk.parseCommit(baseId);
            walk.markStart(tip);
            walk.markUninteresting(base);
            for (;;) {
                RevCommit c = walk.next();
                if (c == null) {
                    break;
                }
                count++;
            }
        } catch (IOException e) {
            // Should never happen, the core receive process would have
            // identified the missing object earlier before we got control.
            LOGGER.error("failed to get commit count", e);
            return 0;
        } finally {
            walk.close();
        }
        return count;
    }
    public static int countCommits(Repository repository, RevWalk walk, String baseId, String tipId) {
        int count = 0;
        try {
            count = countCommits(repository, walk, repository.resolve(baseId),repository.resolve(tipId));
        } catch (IOException e) {
            LOGGER.error("failed to get commit count", e);
        }
        return count;
    }
}
src/main/java/com/gitblit/wicket/GitBlitWebApp.properties
@@ -778,4 +778,6 @@
gb.fileCommitted = Successfully committed {0}.
gb.deletePatchset = Delete Patchset {0}
gb.deletePatchsetSuccess = Deleted Patchset {0}.
gb.deletePatchsetFailure = Error deleting Patchset {0}.
gb.deletePatchsetFailure = Error deleting Patchset {0}.
gb.referencedByCommit = Referenced by commit.
gb.referencedByTicket = Referenced by ticket.
src/main/java/com/gitblit/wicket/pages/TicketPage.html
@@ -461,7 +461,7 @@
                <td style="text-align:right;">
                    <span wicket:id="patchsetType">[revision type]</span>                    
                </td>
                <td><span class="hidden-phone hidden-tablet aui-lozenge aui-lozenge-subtle" wicket:id="patchsetRevision">[R1]</span>
                <td><span class="hidden-phone hidden-tablet" wicket:id="patchsetRevision">[R1]</span>
                    <span class="fa fa-fw" style="padding-left:15px;"><a wicket:id="deleteRevision" class="fa fa-fw fa-trash delete-patchset"></a></span>
                    <span class="hidden-tablet hidden-phone" style="padding-left:15px;"><span wicket:id="patchsetDiffStat"></span></span>
                </td>            
src/main/java/com/gitblit/wicket/pages/TicketPage.java
@@ -36,7 +36,6 @@
import org.apache.wicket.Component;
import org.apache.wicket.MarkupContainer;
import org.apache.wicket.PageParameters;
import org.apache.wicket.RequestCycle;
import org.apache.wicket.RestartResponseException;
import org.apache.wicket.ajax.AjaxRequestTarget;
import org.apache.wicket.behavior.SimpleAttributeModifier;
@@ -45,7 +44,6 @@
import org.apache.wicket.markup.html.link.BookmarkablePageLink;
import org.apache.wicket.markup.html.link.ExternalLink;
import org.apache.wicket.markup.html.link.Link;
import org.apache.wicket.markup.html.link.StatelessLink;
import org.apache.wicket.markup.html.pages.RedirectPage;
import org.apache.wicket.markup.html.panel.Fragment;
import org.apache.wicket.markup.repeater.Item;
@@ -54,7 +52,6 @@
import org.apache.wicket.model.Model;
import org.apache.wicket.protocol.http.RequestUtils;
import org.apache.wicket.protocol.http.WebRequest;
import org.apache.wicket.request.target.basic.RedirectRequestTarget;
import org.eclipse.jgit.diff.DiffEntry.ChangeType;
import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.lib.Ref;
@@ -863,9 +860,6 @@
                if (event.hasPatchset()) {
                    // patchset
                    Patchset patchset = event.patchset;
                    //In the case of using a cached change list
                    item.setVisible(!patchset.isDeleted());
                    String what;
                    if (event.isStatusChange() && (Status.New == event.getStatus())) {
                        what = getString("gb.proposedThisChange");
@@ -883,6 +877,7 @@
                    LinkPanel psr = new LinkPanel("patchsetRevision", null, patchset.number + "-" + patchset.rev,
                            ComparePage.class, WicketUtils.newRangeParameter(repositoryName, patchset.parent == null ? patchset.base : patchset.parent, patchset.tip), true);
                    WicketUtils.setHtmlTooltip(psr, patchset.toString());
                    WicketUtils.setCssClass(psr, "aui-lozenge aui-lozenge-subtle");
                    item.add(psr);
                    String typeCss = getPatchsetTypeCss(patchset.type);
                    Label typeLabel = new Label("patchsetType", patchset.type.toString());
@@ -910,6 +905,42 @@
                    item.add(new Label("patchsetType").setVisible(false));
                    item.add(new Label("deleteRevision").setVisible(false));
                    item.add(new Label("patchsetDiffStat").setVisible(false));
                } else if (event.hasReference()) {
                    // reference
                    switch (event.reference.getSourceType()) {
                        case Commit: {
                            final int shaLen = app().settings().getInteger(Keys.web.shortCommitIdLength, 6);
                            item.add(new Label("what", getString("gb.referencedByCommit")));
                            LinkPanel psr = new LinkPanel("patchsetRevision", null, event.reference.toString().substring(0, shaLen),
                                    CommitPage.class, WicketUtils.newObjectParameter(repositoryName, event.reference.toString()), true);
                            WicketUtils.setHtmlTooltip(psr, event.reference.toString());
                            WicketUtils.setCssClass(psr, "ticketReference-commit shortsha1");
                            item.add(psr);
                        } break;
                        case Ticket: {
                            final String text = MessageFormat.format("ticket/{0}", event.reference.ticketId);
                            item.add(new Label("what", getString("gb.referencedByTicket")));
                            //NOTE: Ideally reference the exact comment using reference.toString,
                            //        however anchor hash is used and is escaped resulting in broken link
                            LinkPanel psr = new LinkPanel("patchsetRevision", null,  text,
                                    TicketsPage.class, WicketUtils.newObjectParameter(repositoryName, event.reference.ticketId.toString()), true);
                            WicketUtils.setCssClass(psr, "ticketReference-comment");
                            item.add(psr);
                        } break;
                        default: {
                            item.add(new Label("what").setVisible(false));
                            item.add(new Label("patchsetRevision").setVisible(false));
                        }
                    }
                    item.add(new Label("patchsetType").setVisible(false));
                    item.add(new Label("deleteRevision").setVisible(false));
                    item.add(new Label("patchsetDiffStat").setVisible(false));
                } else if (event.hasReview()) {
                    // review
                    String score;
src/main/resources/gitblit.css
@@ -2391,4 +2391,19 @@
.delete-patchset {
    color:#D51900;
    font-size: 1.2em;
}
.ticketReference-comment {
    font-family: sans-serif;
    font-weight: 200;
    font-size: 1em;
    font-variant: normal;
    text-transform: none;
}
.ticketReference-commit {
    font-family: monospace;
    font-weight: 200;
    font-size: 1em;
    font-variant: normal;
}
src/test/config/test-gitblit.properties
@@ -90,3 +90,5 @@
server.httpsBindInterface = localhost
server.storePassword = gitblit
server.shutdownPort = 8081
tickets.service = com.gitblit.tickets.BranchTicketService
src/test/java/com/gitblit/tests/GitBlitSuite.java
@@ -66,7 +66,7 @@
        ModelUtilsTest.class, JnaUtilsTest.class, LdapSyncServiceTest.class, FileTicketServiceTest.class,
        BranchTicketServiceTest.class, RedisTicketServiceTest.class, AuthenticationManagerTest.class,
        SshKeysDispatcherTest.class, UITicketTest.class, PathUtilsTest.class, SshKerberosAuthenticationTest.class,
        GravatarTest.class, FilestoreManagerTest.class, FilestoreServletTest.class })
        GravatarTest.class, FilestoreManagerTest.class, FilestoreServletTest.class, TicketReferenceTest.class })
public class GitBlitSuite {
    public static final File BASEFOLDER = new File("data");
src/test/java/com/gitblit/tests/TicketReferenceTest.java
New file
@@ -0,0 +1,939 @@
/*
 * Copyright 2016 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 java.io.BufferedWriter;
import java.io.File;
import java.io.FileOutputStream;
import java.io.OutputStreamWriter;
import java.text.MessageFormat;
import java.util.Date;
import java.util.List;
import org.eclipse.jgit.api.CloneCommand;
import org.eclipse.jgit.api.Git;
import org.eclipse.jgit.api.MergeResult;
import org.eclipse.jgit.api.MergeCommand.FastForwardMode;
import org.eclipse.jgit.lib.Constants;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.transport.CredentialsProvider;
import org.eclipse.jgit.transport.PushResult;
import org.eclipse.jgit.transport.RefSpec;
import org.eclipse.jgit.transport.RemoteRefUpdate;
import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider;
import org.eclipse.jgit.transport.RemoteRefUpdate.Status;
import org.eclipse.jgit.util.FileUtils;
import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.Test;
import com.gitblit.Constants.AccessPermission;
import com.gitblit.Constants.AccessRestrictionType;
import com.gitblit.Constants.AuthorizationControl;
import com.gitblit.GitBlitException;
import com.gitblit.models.RepositoryModel;
import com.gitblit.models.TicketModel;
import com.gitblit.models.UserModel;
import com.gitblit.models.TicketModel.Change;
import com.gitblit.models.TicketModel.Field;
import com.gitblit.models.TicketModel.Reference;
import com.gitblit.tickets.ITicketService;
/**
 * Creates and deletes a range of ticket references via ticket comments and commits
 */
public class TicketReferenceTest extends GitblitUnitTest {
    static File workingCopy = new File(GitBlitSuite.REPOSITORIES, "working/TicketReferenceTest.git-wc");
    static ITicketService ticketService;
    static final String account = "TicketRefTest";
    static final String password = GitBlitSuite.password;
    static final String url = GitBlitSuite.gitServletUrl;
    static UserModel user = null;
    static RepositoryModel repo = null;
    static CredentialsProvider cp = null;
    static Git git = null;
    @BeforeClass
    public static void configure() throws Exception {
        File repositoryName = new File("TicketReferenceTest.git");;
        GitBlitSuite.close(repositoryName);
        if (repositoryName.exists()) {
            FileUtils.delete(repositoryName, FileUtils.RECURSIVE | FileUtils.RETRY);
        }
        repo = new RepositoryModel("TicketReferenceTest.git", null, null, null);
        if (gitblit().hasRepository(repo.name)) {
            gitblit().deleteRepositoryModel(repo);
        }
        gitblit().updateRepositoryModel(repo.name, repo, true);
        user = new UserModel(account);
        user.displayName = account;
        user.emailAddress = account + "@example.com";
        user.password = password;
        cp = new UsernamePasswordCredentialsProvider(user.username, user.password);
        if (gitblit().getUserModel(user.username) != null) {
            gitblit().deleteUser(user.username);
        }
        repo.authorizationControl = AuthorizationControl.NAMED;
        repo.accessRestriction = AccessRestrictionType.PUSH;
        gitblit().updateRepositoryModel(repo.name, repo, false);
        // grant user push permission
        user.setRepositoryPermission(repo.name, AccessPermission.REWIND);
        gitblit().updateUserModel(user);
        ticketService = gitblit().getTicketService();
        assertTrue(ticketService.deleteAll(repo));
        GitBlitSuite.close(workingCopy);
        if (workingCopy.exists()) {
            FileUtils.delete(workingCopy, FileUtils.RECURSIVE | FileUtils.RETRY);
        }
        CloneCommand clone = Git.cloneRepository();
        clone.setURI(MessageFormat.format("{0}/{1}", url, repo.name));
        clone.setDirectory(workingCopy);
        clone.setBare(false);
        clone.setBranch("master");
        clone.setCredentialsProvider(cp);
        GitBlitSuite.close(clone.call());
        git = Git.open(workingCopy);
        git.getRepository().getConfig().setString("user", null, "name", user.displayName);
        git.getRepository().getConfig().setString("user", null, "email", user.emailAddress);
        git.getRepository().getConfig().save();
        final RevCommit revCommit1 = makeCommit("initial commit");
        final String initialSha = revCommit1.name();
        Iterable<PushResult> results = git.push().setPushAll().setCredentialsProvider(cp).call();
        GitBlitSuite.close(git);
        for (PushResult result : results) {
            for (RemoteRefUpdate update : result.getRemoteUpdates()) {
                assertEquals(Status.OK, update.getStatus());
                assertEquals(initialSha, update.getNewObjectId().name());
            }
        }
    }
    @AfterClass
    public static void cleanup() throws Exception {
        GitBlitSuite.close(git);
    }
    @Test
    public void noReferencesOnTicketCreation() throws Exception {
        TicketModel a = ticketService.createTicket(repo, newTicket("noReferencesOnCreation"));
        assertNotNull(a);
        assertFalse(a.hasReferences());
        //Ensure retrieval process doesn't affect anything
        a = ticketService.getTicket(repo,  a.number);
        assertNotNull(a);
        assertFalse(a.hasReferences());
    }
    @Test
    public void commentNoUnexpectedReference() throws Exception {
        TicketModel a = ticketService.createTicket(repo, newTicket("commentNoUnexpectedReference-A"));
        TicketModel b = ticketService.createTicket(repo, newTicket("commentNoUnexpectedReference-B"));
        assertNotNull(ticketService.updateTicket(repo, a.number, newComment("comment for 1 - no reference")));
        assertNotNull(ticketService.updateTicket(repo, a.number, newComment("comment for # - no reference")));
        assertNotNull(ticketService.updateTicket(repo, a.number, newComment("comment for #42 - ignores invalid reference")));
        a = ticketService.getTicket(repo, a.number);
        b = ticketService.getTicket(repo, b.number);
        assertFalse(a.hasReferences());
        assertFalse(b.hasReferences());
    }
    @Test
    public void commentNoSelfReference() throws Exception {
        TicketModel a = ticketService.createTicket(repo, newTicket("commentNoSelfReference-A"));
        final Change comment = newComment(String.format("comment for #%d - no self reference", a.number));
        assertNotNull(ticketService.updateTicket(repo, a.number, comment));
        a = ticketService.getTicket(repo, a.number);
        assertFalse(a.hasReferences());
    }
    @Test
    public void commentSingleReference() throws Exception {
        TicketModel a = ticketService.createTicket(repo, newTicket("commentSingleReference-A"));
        TicketModel b = ticketService.createTicket(repo, newTicket("commentSingleReference-B"));
        final Change comment = newComment(String.format("comment for #%d - single reference", b.number));
        assertNotNull(ticketService.updateTicket(repo, a.number, comment));
        a = ticketService.getTicket(repo, a.number);
        b = ticketService.getTicket(repo, b.number);
        assertFalse(a.hasReferences());
        assertTrue(b.hasReferences());
        List<Reference> cRefB = b.getReferences();
        assertNotNull(cRefB);
        assertEquals(1, cRefB.size());
        assertEquals(a.number, cRefB.get(0).ticketId.longValue());
        assertEquals(comment.comment.id, cRefB.get(0).hash);
    }
    @Test
    public void commentSelfAndOtherReference() throws Exception {
        TicketModel a = ticketService.createTicket(repo, newTicket("commentSelfAndOtherReference-A"));
        TicketModel b = ticketService.createTicket(repo, newTicket("commentSelfAndOtherReference-B"));
        final Change comment = newComment(String.format("comment for #%d and #%d - self and other reference", a.number, b.number));
        assertNotNull(ticketService.updateTicket(repo, a.number, comment));
        a = ticketService.getTicket(repo, a.number);
        b = ticketService.getTicket(repo, b.number);
        assertFalse(a.hasReferences());
        assertTrue(b.hasReferences());
        List<Reference> cRefB = b.getReferences();
        assertNotNull(cRefB);
        assertEquals(1, cRefB.size());
        assertEquals(a.number, cRefB.get(0).ticketId.longValue());
        assertEquals(comment.comment.id, cRefB.get(0).hash);
    }
    @Test
    public void commentMultiReference() throws Exception {
        TicketModel a = ticketService.createTicket(repo, newTicket("commentMultiReference-A"));
        TicketModel b = ticketService.createTicket(repo, newTicket("commentMultiReference-B"));
        TicketModel c = ticketService.createTicket(repo, newTicket("commentMultiReference-C"));
        final Change comment = newComment(String.format("comment for #%d and #%d - multi reference", b.number, c.number));
        assertNotNull(ticketService.updateTicket(repo, a.number, comment));
        a = ticketService.getTicket(repo, a.number);
        b = ticketService.getTicket(repo, b.number);
        c = ticketService.getTicket(repo, c.number);
        assertFalse(a.hasReferences());
        assertTrue(b.hasReferences());
        assertTrue(c.hasReferences());
        List<Reference> cRefB = b.getReferences();
        assertNotNull(cRefB);
        assertEquals(1, cRefB.size());
        assertEquals(a.number, cRefB.get(0).ticketId.longValue());
        assertEquals(comment.comment.id, cRefB.get(0).hash);
        List<Reference> cRefC = c.getReferences();
        assertNotNull(cRefC);
        assertEquals(1, cRefC.size());
        assertEquals(a.number, cRefC.get(0).ticketId.longValue());
        assertEquals(comment.comment.id, cRefC.get(0).hash);
    }
    @Test
    public void commitMasterNoUnexpectedReference() throws Exception {
        TicketModel a = ticketService.createTicket(repo, newTicket("commentMultiReference-A"));
        final String branchName = "master";
        git.checkout().setCreateBranch(false).setName(branchName).call();
        makeCommit("commit for 1 - no reference");
        makeCommit("comment for # - no reference");
        final RevCommit revCommit1 = makeCommit("comment for #42 - ignores invalid reference");
        final String commit1Sha = revCommit1.name();
        assertPushSuccess(commit1Sha, branchName);
        a = ticketService.getTicket(repo, a.number);
        assertFalse(a.hasReferences());
    }
    @Test
    public void commitMasterSingleReference() throws Exception {
        TicketModel a = ticketService.createTicket(repo, newTicket("commitMasterSingleReference-A"));
        final String branchName = "master";
        git.checkout().setCreateBranch(false).setName(branchName).call();
        final String message = String.format("commit for #%d - single reference", a.number);
        final RevCommit revCommit1 = makeCommit(message);
        final String commit1Sha = revCommit1.name();
        assertPushSuccess(commit1Sha, branchName);
        a = ticketService.getTicket(repo, a.number);
        assertTrue(a.hasReferences());
        List<Reference> cRefA = a.getReferences();
        assertNotNull(cRefA);
        assertEquals(1, cRefA.size());
        assertNull(cRefA.get(0).ticketId);
        assertEquals(commit1Sha, cRefA.get(0).hash);
    }
    @Test
    public void commitMasterMultiReference() throws Exception {
        TicketModel a = ticketService.createTicket(repo, newTicket("commitMasterMultiReference-A"));
        TicketModel b = ticketService.createTicket(repo, newTicket("commitMasterMultiReference-B"));
        final String branchName = "master";
        git.checkout().setCreateBranch(false).setName(branchName).call();
        final String message = String.format("commit for #%d and #%d - multi reference", a.number, b.number);
        final RevCommit revCommit1 = makeCommit(message);
        final String commit1Sha = revCommit1.name();
        assertPushSuccess(commit1Sha, branchName);
        a = ticketService.getTicket(repo, a.number);
        b = ticketService.getTicket(repo, b.number);
        assertTrue(a.hasReferences());
        assertTrue(b.hasReferences());
        List<Reference> cRefA = a.getReferences();
        assertNotNull(cRefA);
        assertEquals(1, cRefA.size());
        assertNull(cRefA.get(0).ticketId);
        assertEquals(commit1Sha, cRefA.get(0).hash);
        List<Reference> cRefB = a.getReferences();
        assertNotNull(cRefB);
        assertEquals(1, cRefB.size());
        assertNull(cRefB.get(0).ticketId);
        assertEquals(commit1Sha, cRefB.get(0).hash);
    }
    @Test
    public void commitMasterAmendReference() throws Exception {
        TicketModel a = ticketService.createTicket(repo, newTicket("commitMasterAmendReference-A"));
        TicketModel b = ticketService.createTicket(repo, newTicket("commitMasterAmendReference-B"));
        final String branchName = "master";
        git.checkout().setCreateBranch(false).setName(branchName).call();
        String message = String.format("commit before amend for #%d and #%d", a.number, b.number);
        final RevCommit revCommit1 = makeCommit(message);
        final String commit1Sha = revCommit1.name();
        assertPushSuccess(commit1Sha, branchName);
        a = ticketService.getTicket(repo, a.number);
        b = ticketService.getTicket(repo, b.number);
        assertTrue(a.hasReferences());
        assertTrue(b.hasReferences());
        List<Reference> cRefA = a.getReferences();
        assertNotNull(cRefA);
        assertEquals(1, cRefA.size());
        assertNull(cRefA.get(0).ticketId);
        assertEquals(commit1Sha, cRefA.get(0).hash);
        List<Reference> cRefB = b.getReferences();
        assertNotNull(cRefB);
        assertEquals(1, cRefB.size());
        assertNull(cRefB.get(0).ticketId);
        assertEquals(commit1Sha, cRefB.get(0).hash);
        //Confirm that old invalid references removed for both tickets
        //and new reference added for one referenced ticket
        message = String.format("commit after amend for #%d", a.number);
        final String commit2Sha = amendCommit(message);
        assertForcePushSuccess(commit2Sha, branchName);
        a = ticketService.getTicket(repo, a.number);
        b = ticketService.getTicket(repo, b.number);
        assertTrue(a.hasReferences());
        assertFalse(b.hasReferences());
        cRefA = a.getReferences();
        assertNotNull(cRefA);
        assertEquals(1, cRefA.size());
        assertNull(cRefA.get(0).ticketId);
        assertEquals(commit2Sha, cRefA.get(0).hash);
    }
    @Test
    public void commitPatchsetNoUnexpectedReference() throws Exception {
        setPatchsetAvailable(true);
        TicketModel a = ticketService.createTicket(repo, newTicket("commitPatchsetNoUnexpectedReference-A"));
        String branchName = String.format("ticket/%d", a.number);
        git.checkout().setCreateBranch(true).setName(branchName).call();
        makeCommit("commit for 1 - no reference");
        makeCommit("commit for # - no reference");
        final String message = "commit for #42 - ignores invalid reference";
        final RevCommit revCommit1 = makeCommit(message);
        final String commit1Sha = revCommit1.name();
        assertPushSuccess(commit1Sha, branchName);
        a = ticketService.getTicket(repo, a.number);
        assertFalse(a.hasReferences());
    }
    @Test
    public void commitPatchsetNoSelfReference() throws Exception {
        setPatchsetAvailable(true);
        TicketModel a = ticketService.createTicket(repo, newTicket("commitPatchsetNoSelfReference-A"));
        String branchName = String.format("ticket/%d", a.number);
        git.checkout().setCreateBranch(true).setName(branchName).call();
        final String message = String.format("commit for #%d - patchset self reference", a.number);
        final RevCommit revCommit1 = makeCommit(message);
        final String commit1Sha = revCommit1.name();
        assertPushSuccess(commit1Sha, branchName);
        a = ticketService.getTicket(repo, a.number);
        assertFalse(a.hasReferences());
    }
    @Test
    public void commitPatchsetSingleReference() throws Exception {
        setPatchsetAvailable(true);
        TicketModel a = ticketService.createTicket(repo, newTicket("commitPatchsetSingleReference-A"));
        TicketModel b = ticketService.createTicket(repo, newTicket("commitPatchsetSingleReference-B"));
        String branchName = String.format("ticket/%d", a.number);
        git.checkout().setCreateBranch(true).setName(branchName).call();
        final String message = String.format("commit for #%d - patchset single reference", b.number);
        final RevCommit revCommit1 = makeCommit(message);
        final String commit1Sha = revCommit1.name();
        assertPushSuccess(commit1Sha, branchName);
        a = ticketService.getTicket(repo, a.number);
        b = ticketService.getTicket(repo, b.number);
        assertFalse(a.hasReferences());
        assertTrue(b.hasReferences());
        List<Reference> cRefB = b.getReferences();
        assertNotNull(cRefB);
        assertEquals(1, cRefB.size());
        assertNull(cRefB.get(0).ticketId);
        assertEquals(commit1Sha, cRefB.get(0).hash);
    }
    @Test
    public void commitPatchsetMultiReference() throws Exception {
        setPatchsetAvailable(true);
        TicketModel a = ticketService.createTicket(repo, newTicket("commitPatchsetMultiReference-A"));
        TicketModel b = ticketService.createTicket(repo, newTicket("commitPatchsetMultiReference-B"));
        TicketModel c = ticketService.createTicket(repo, newTicket("commitPatchsetMultiReference-C"));
        String branchName = String.format("ticket/%d", a.number);
        git.checkout().setCreateBranch(true).setName(branchName).call();
        final String message = String.format("commit for #%d and #%d- patchset multi reference", b.number, c.number);
        final RevCommit revCommit1 = makeCommit(message);
        final String commit1Sha = revCommit1.name();
        assertPushSuccess(commit1Sha, branchName);
        a = ticketService.getTicket(repo, a.number);
        b = ticketService.getTicket(repo, b.number);
        c = ticketService.getTicket(repo, c.number);
        assertFalse(a.hasReferences());
        assertTrue(b.hasReferences());
        assertTrue(c.hasReferences());
        List<Reference> cRefB = b.getReferences();
        assertNotNull(cRefB);
        assertEquals(1, cRefB.size());
        assertNull(cRefB.get(0).ticketId);
        assertEquals(commit1Sha, cRefB.get(0).hash);
        List<Reference> cRefC = c.getReferences();
        assertNotNull(cRefC);
        assertEquals(1, cRefC.size());
        assertNull(cRefC.get(0).ticketId);
        assertEquals(commit1Sha, cRefC.get(0).hash);
    }
    @Test
    public void commitPatchsetAmendReference() throws Exception {
        setPatchsetAvailable(true);
        TicketModel a = ticketService.createTicket(repo, newTicket("commitPatchsetAmendReference-A"));
        TicketModel b = ticketService.createTicket(repo, newTicket("commitPatchsetAmendReference-B"));
        TicketModel c = ticketService.createTicket(repo, newTicket("commitPatchsetAmendReference-C"));
        assertFalse(c.hasPatchsets());
        String branchName = String.format("ticket/%d", c.number);
        git.checkout().setCreateBranch(true).setName(branchName).call();
        String message = String.format("commit before amend for #%d and #%d", a.number, b.number);
        final RevCommit revCommit1 = makeCommit(message);
        final String commit1Sha = revCommit1.name();
        assertPushSuccess(commit1Sha, branchName);
        a = ticketService.getTicket(repo, a.number);
        b = ticketService.getTicket(repo, b.number);
        c = ticketService.getTicket(repo, c.number);
        assertTrue(a.hasReferences());
        assertTrue(b.hasReferences());
        assertFalse(c.hasReferences());
        assertTrue(c.hasPatchsets());
        assertNotNull(c.getPatchset(1, 1));
        List<Reference> cRefA = a.getReferences();
        assertNotNull(cRefA);
        assertEquals(1, cRefA.size());
        assertNull(cRefA.get(0).ticketId);
        assertEquals(commit1Sha, cRefA.get(0).hash);
        List<Reference> cRefB = b.getReferences();
        assertNotNull(cRefB);
        assertEquals(1, cRefB.size());
        assertNull(cRefB.get(0).ticketId);
        assertEquals(commit1Sha, cRefB.get(0).hash);
        //As a new patchset is created the references will remain until deleted
        message = String.format("commit after amend for #%d", a.number);
        final String commit2Sha = amendCommit(message);
        assertForcePushSuccess(commit2Sha, branchName);
        a = ticketService.getTicket(repo, a.number);
        b = ticketService.getTicket(repo, b.number);
        c = ticketService.getTicket(repo, c.number);
        assertTrue(a.hasReferences());
        assertTrue(b.hasReferences());
        assertFalse(c.hasReferences());
        assertNotNull(c.getPatchset(1, 1));
        assertNotNull(c.getPatchset(2, 1));
        cRefA = a.getReferences();
        assertNotNull(cRefA);
        assertEquals(2, cRefA.size());
        assertNull(cRefA.get(0).ticketId);
        assertNull(cRefA.get(1).ticketId);
        assertEquals(commit1Sha, cRefA.get(0).hash);
        assertEquals(commit2Sha, cRefA.get(1).hash);
        cRefB = b.getReferences();
        assertNotNull(cRefB);
        assertEquals(1, cRefB.size());
        assertNull(cRefB.get(0).ticketId);
        assertEquals(commit1Sha, cRefB.get(0).hash);
        //Delete the original patchset and confirm old references are removed
        ticketService.deletePatchset(c, c.getPatchset(1, 1), user.username);
        a = ticketService.getTicket(repo, a.number);
        b = ticketService.getTicket(repo, b.number);
        c = ticketService.getTicket(repo, c.number);
        assertTrue(a.hasReferences());
        assertFalse(b.hasReferences());
        assertFalse(c.hasReferences());
        assertNull(c.getPatchset(1, 1));
        assertNotNull(c.getPatchset(2, 1));
        cRefA = a.getReferences();
        assertNotNull(cRefA);
        assertEquals(1, cRefA.size());
        assertNull(cRefA.get(0).ticketId);
        assertEquals(commit2Sha, cRefA.get(0).hash);
    }
    @Test
    public void commitTicketBranchNoUnexpectedReference() throws Exception {
        setPatchsetAvailable(false);
        TicketModel a = ticketService.createTicket(repo, newTicket("commitTicketBranchNoUnexpectedReference-A"));
        String branchName = String.format("ticket/%d", a.number);
        git.checkout().setCreateBranch(true).setName(branchName).call();
        makeCommit("commit for 1 - no reference");
        makeCommit("commit for # - no reference");
        final String message = "commit for #42 - ignores invalid reference";
        final RevCommit revCommit1 = makeCommit(message);
        final String commit1Sha = revCommit1.name();
        assertPushSuccess(commit1Sha, branchName);
        a = ticketService.getTicket(repo, a.number);
        assertFalse(a.hasReferences());
    }
    @Test
    public void commitTicketBranchSelfReference() throws Exception {
        setPatchsetAvailable(false);
        TicketModel a = ticketService.createTicket(repo, newTicket("commitTicketBranchSelfReference-A"));
        String branchName = String.format("ticket/%d", a.number);
        git.checkout().setCreateBranch(true).setName(branchName).call();
        final String message = String.format("commit for #%d - patchset self reference", a.number);
        final RevCommit revCommit1 = makeCommit(message);
        final String commit1Sha = revCommit1.name();
        assertPushSuccess(commit1Sha, branchName);
        a = ticketService.getTicket(repo, a.number);
        assertTrue(a.hasReferences());
        List<Reference> cRefA = a.getReferences();
        assertNotNull(cRefA);
        assertEquals(1, cRefA.size());
        assertNull(cRefA.get(0).ticketId);
        assertEquals(commit1Sha, cRefA.get(0).hash);
    }
    @Test
    public void commitTicketBranchSingleReference() throws Exception {
        setPatchsetAvailable(false);
        TicketModel a = ticketService.createTicket(repo, newTicket("commitTicketBranchSingleReference-A"));
        TicketModel b = ticketService.createTicket(repo, newTicket("commitTicketBranchSingleReference-B"));
        String branchName = String.format("ticket/%d", a.number);
        git.checkout().setCreateBranch(true).setName(branchName).call();
        final String message = String.format("commit for #%d - patchset single reference", b.number);
        final RevCommit revCommit1 = makeCommit(message);
        final String commit1Sha = revCommit1.name();
        assertPushSuccess(commit1Sha, branchName);
        a = ticketService.getTicket(repo, a.number);
        b = ticketService.getTicket(repo, b.number);
        assertFalse(a.hasReferences());
        assertTrue(b.hasReferences());
        List<Reference> cRefB = b.getReferences();
        assertNotNull(cRefB);
        assertEquals(1, cRefB.size());
        assertNull(cRefB.get(0).ticketId);
        assertEquals(commit1Sha, cRefB.get(0).hash);
    }
    @Test
    public void commitTicketBranchMultiReference() throws Exception {
        setPatchsetAvailable(false);
        TicketModel a = ticketService.createTicket(repo, newTicket("commitTicketBranchMultiReference-A"));
        TicketModel b = ticketService.createTicket(repo, newTicket("commitTicketBranchMultiReference-B"));
        TicketModel c = ticketService.createTicket(repo, newTicket("commitTicketBranchMultiReference-C"));
        String branchName = String.format("ticket/%d", a.number);
        git.checkout().setCreateBranch(true).setName(branchName).call();
        final String message = String.format("commit for #%d and #%d- patchset multi reference", b.number, c.number);
        final RevCommit revCommit1 = makeCommit(message);
        final String commit1Sha = revCommit1.name();
        assertPushSuccess(commit1Sha, branchName);
        a = ticketService.getTicket(repo, a.number);
        b = ticketService.getTicket(repo, b.number);
        c = ticketService.getTicket(repo, c.number);
        assertFalse(a.hasReferences());
        assertTrue(b.hasReferences());
        assertTrue(c.hasReferences());
        List<Reference> cRefB = b.getReferences();
        assertNotNull(cRefB);
        assertEquals(1, cRefB.size());
        assertNull(cRefB.get(0).ticketId);
        assertEquals(commit1Sha, cRefB.get(0).hash);
        List<Reference> cRefC = c.getReferences();
        assertNotNull(cRefC);
        assertEquals(1, cRefC.size());
        assertNull(cRefC.get(0).ticketId);
        assertEquals(commit1Sha, cRefC.get(0).hash);
    }
    @Test
    public void commitTicketBranchAmendReference() throws Exception {
        setPatchsetAvailable(false);
        TicketModel a = ticketService.createTicket(repo, newTicket("commitTicketBranchAmendReference-A"));
        TicketModel b = ticketService.createTicket(repo, newTicket("commitTicketBranchAmendReference-B"));
        TicketModel c = ticketService.createTicket(repo, newTicket("commitTicketBranchAmendReference-C"));
        assertFalse(c.hasPatchsets());
        String branchName = String.format("ticket/%d", c.number);
        git.checkout().setCreateBranch(true).setName(branchName).call();
        String message = String.format("commit before amend for #%d and #%d", a.number, b.number);
        final RevCommit revCommit1 = makeCommit(message);
        final String commit1Sha = revCommit1.name();
        assertPushSuccess(commit1Sha, branchName);
        a = ticketService.getTicket(repo, a.number);
        b = ticketService.getTicket(repo, b.number);
        c = ticketService.getTicket(repo, c.number);
        assertTrue(a.hasReferences());
        assertTrue(b.hasReferences());
        assertFalse(c.hasReferences());
        assertFalse(c.hasPatchsets());
        List<Reference> cRefA = a.getReferences();
        assertNotNull(cRefA);
        assertEquals(1, cRefA.size());
        assertNull(cRefA.get(0).ticketId);
        assertEquals(commit1Sha, cRefA.get(0).hash);
        List<Reference> cRefB = b.getReferences();
        assertNotNull(cRefB);
        assertEquals(1, cRefB.size());
        assertNull(cRefB.get(0).ticketId);
        assertEquals(commit1Sha, cRefB.get(0).hash);
        //Confirm that old invalid references removed for both tickets
        //and new reference added for one referenced ticket
        message = String.format("commit after amend for #%d", a.number);
        final String commit2Sha = amendCommit(message);
        assertForcePushSuccess(commit2Sha, branchName);
        a = ticketService.getTicket(repo, a.number);
        b = ticketService.getTicket(repo, b.number);
        c = ticketService.getTicket(repo, c.number);
        assertTrue(a.hasReferences());
        assertFalse(b.hasReferences());
        assertFalse(c.hasReferences());
        assertFalse(c.hasPatchsets());
        cRefA = a.getReferences();
        assertNotNull(cRefA);
        assertEquals(1, cRefA.size());
        assertNull(cRefA.get(0).ticketId);
        assertEquals(commit2Sha, cRefA.get(0).hash);
    }
    @Test
    public void commitTicketBranchDeleteNoMergeReference() throws Exception {
        setPatchsetAvailable(false);
        TicketModel a = ticketService.createTicket(repo, newTicket("commitTicketBranchDeleteNoMergeReference-A"));
        TicketModel b = ticketService.createTicket(repo, newTicket("commitTicketBranchDeleteNoMergeReference-B"));
        TicketModel c = ticketService.createTicket(repo, newTicket("commitTicketBranchDeleteNoMergeReference-C"));
        assertFalse(c.hasPatchsets());
        String branchName = String.format("ticket/%d", c.number);
        git.checkout().setCreateBranch(true).setName(branchName).call();
        String message = String.format("commit before amend for #%d and #%d", a.number, b.number);
        final RevCommit revCommit1 = makeCommit(message);
        final String commit1Sha = revCommit1.name();
        assertPushSuccess(commit1Sha, branchName);
        a = ticketService.getTicket(repo, a.number);
        b = ticketService.getTicket(repo, b.number);
        c = ticketService.getTicket(repo, c.number);
        assertTrue(a.hasReferences());
        assertTrue(b.hasReferences());
        assertFalse(c.hasReferences());
        List<Reference> cRefA = a.getReferences();
        assertNotNull(cRefA);
        assertEquals(1, cRefA.size());
        assertNull(cRefA.get(0).ticketId);
        assertEquals(commit1Sha, cRefA.get(0).hash);
        List<Reference> cRefB = b.getReferences();
        assertNotNull(cRefB);
        assertEquals(1, cRefB.size());
        assertNull(cRefB.get(0).ticketId);
        assertEquals(commit1Sha, cRefB.get(0).hash);
        //Confirm that old invalid references removed for both tickets
        assertDeleteBranch(branchName);
        a = ticketService.getTicket(repo, a.number);
        b = ticketService.getTicket(repo, b.number);
        c = ticketService.getTicket(repo, c.number);
        assertFalse(a.hasReferences());
        assertFalse(b.hasReferences());
        assertFalse(c.hasReferences());
    }
    @Test
    public void commitTicketBranchDeletePostMergeReference() throws Exception {
        setPatchsetAvailable(false);
        TicketModel a = ticketService.createTicket(repo, newTicket("commitTicketBranchDeletePostMergeReference-A"));
        TicketModel b = ticketService.createTicket(repo, newTicket("commitTicketBranchDeletePostMergeReference-B"));
        TicketModel c = ticketService.createTicket(repo, newTicket("commitTicketBranchDeletePostMergeReference-C"));
        assertFalse(c.hasPatchsets());
        String branchName = String.format("ticket/%d", c.number);
        git.checkout().setCreateBranch(true).setName(branchName).call();
        String message = String.format("commit before amend for #%d and #%d", a.number, b.number);
        final RevCommit revCommit1 = makeCommit(message);
        final String commit1Sha = revCommit1.name();
        assertPushSuccess(commit1Sha, branchName);
        a = ticketService.getTicket(repo, a.number);
        b = ticketService.getTicket(repo, b.number);
        c = ticketService.getTicket(repo, c.number);
        assertTrue(a.hasReferences());
        assertTrue(b.hasReferences());
        assertFalse(c.hasReferences());
        List<Reference> cRefA = a.getReferences();
        assertNotNull(cRefA);
        assertEquals(1, cRefA.size());
        assertNull(cRefA.get(0).ticketId);
        assertEquals(commit1Sha, cRefA.get(0).hash);
        List<Reference> cRefB = b.getReferences();
        assertNotNull(cRefB);
        assertEquals(1, cRefB.size());
        assertNull(cRefB.get(0).ticketId);
        assertEquals(commit1Sha, cRefB.get(0).hash);
        git.checkout().setCreateBranch(false).setName("refs/heads/master").call();
        // merge the tip of the branch into master
        MergeResult mergeResult = git.merge().setFastForward(FastForwardMode.NO_FF).include(revCommit1.getId()).call();
        assertEquals(MergeResult.MergeStatus.MERGED, mergeResult.getMergeStatus());
        // push the merged master to the origin
        Iterable<PushResult> results = git.push().setCredentialsProvider(cp).setRemote("origin").call();
        for (PushResult result : results) {
            RemoteRefUpdate ref = result.getRemoteUpdate("refs/heads/master");
            assertEquals(Status.OK, ref.getStatus());
        }
        //As everything has been merged no references should be changed
        assertDeleteBranch(branchName);
        a = ticketService.getTicket(repo, a.number);
        b = ticketService.getTicket(repo, b.number);
        c = ticketService.getTicket(repo, c.number);
        assertTrue(a.hasReferences());
        assertTrue(b.hasReferences());
        assertFalse(c.hasReferences());
        cRefA = a.getReferences();
        assertNotNull(cRefA);
        assertEquals(1, cRefA.size());
        assertNull(cRefA.get(0).ticketId);
        assertEquals(commit1Sha, cRefA.get(0).hash);
        cRefB = b.getReferences();
        assertNotNull(cRefB);
        assertEquals(1, cRefB.size());
        assertNull(cRefB.get(0).ticketId);
        assertEquals(commit1Sha, cRefB.get(0).hash);
    }
    private static Change newComment(String text) {
        Change change = new Change("JUnit");
        change.comment(text);
        return change;
    }
    private static Change newTicket(String title) {
        Change change = new Change("JUnit");
        change.setField(Field.title, title);
        change.setField(Field.type, TicketModel.Type.Bug );
        return change;
    }
    private static RevCommit makeCommit(String message) throws Exception {
        File file = new File(workingCopy, "testFile.txt");
        OutputStreamWriter os = new OutputStreamWriter(new FileOutputStream(file, true), Constants.CHARSET);
        BufferedWriter w = new BufferedWriter(os);
        w.write("// " + new Date().toString() + "\n");
        w.close();
        git.add().addFilepattern(file.getName()).call();
        RevCommit rev = git.commit().setMessage(message).call();
        return rev;
    }
    private static String amendCommit(String message) throws Exception {
        File file = new File(workingCopy, "testFile.txt");
        OutputStreamWriter os = new OutputStreamWriter(new FileOutputStream(file, true), Constants.CHARSET);
        BufferedWriter w = new BufferedWriter(os);
        w.write("// " + new Date().toString() + "\n");
        w.close();
        git.add().addFilepattern(file.getName()).call();
        RevCommit rev = git.commit().setAmend(true).setMessage(message).call();
        return rev.getId().name();
    }
    private void setPatchsetAvailable(boolean state) throws GitBlitException {
        repo.acceptNewPatchsets = state;
        gitblit().updateRepositoryModel(repo.name, repo, false);
    }
    private void assertPushSuccess(String commitSha, String branchName) throws Exception {
        Iterable<PushResult> results = git.push().setRemote("origin").setCredentialsProvider(cp).call();
        for (PushResult result : results) {
            RemoteRefUpdate ref = result.getRemoteUpdate("refs/heads/" + branchName);
            assertEquals(Status.OK, ref.getStatus());
            assertEquals(commitSha, ref.getNewObjectId().name());
        }
    }
    private void assertForcePushSuccess(String commitSha, String branchName) throws Exception {
        Iterable<PushResult> results = git.push().setForce(true).setRemote("origin").setCredentialsProvider(cp).call();
        for (PushResult result : results) {
            RemoteRefUpdate ref = result.getRemoteUpdate("refs/heads/" + branchName);
            assertEquals(Status.OK, ref.getStatus());
            assertEquals(commitSha, ref.getNewObjectId().name());
        }
    }
    private void assertDeleteBranch(String branchName) throws Exception {
        RefSpec refSpec = new RefSpec()
        .setSource(null)
        .setDestination("refs/heads/" + branchName);
        Iterable<PushResult> results = git.push().setRefSpecs(refSpec).setRemote("origin").setCredentialsProvider(cp).call();
        for (PushResult result : results) {
            RemoteRefUpdate ref = result.getRemoteUpdate("refs/heads/" + branchName);
            assertEquals(Status.OK, ref.getStatus());
        }
    }
}