com.gitblit.tickets.TicketNotifier.java Source code

Java tutorial

Introduction

Here is the source code for com.gitblit.tickets.TicketNotifier.java

Source

/*
 * Copyright 2014 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.tickets;

import java.io.IOException;
import java.io.InputStream;
import java.text.DateFormat;
import java.text.MessageFormat;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.apache.commons.io.IOUtils;
import org.apache.log4j.Logger;
import org.eclipse.jgit.diff.DiffEntry.ChangeType;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevCommit;
import org.slf4j.LoggerFactory;

import com.gitblit.Constants;
import com.gitblit.IStoredSettings;
import com.gitblit.Keys;
import com.gitblit.git.PatchsetCommand;
import com.gitblit.manager.INotificationManager;
import com.gitblit.manager.IRepositoryManager;
import com.gitblit.manager.IRuntimeManager;
import com.gitblit.manager.IUserManager;
import com.gitblit.models.Mailing;
import com.gitblit.models.PathModel.PathChangeModel;
import com.gitblit.models.RepositoryModel;
import com.gitblit.models.TicketModel;
import com.gitblit.models.TicketModel.Change;
import com.gitblit.models.TicketModel.Field;
import com.gitblit.models.TicketModel.Patchset;
import com.gitblit.models.TicketModel.Review;
import com.gitblit.models.TicketModel.Status;
import com.gitblit.models.UserModel;
import com.gitblit.utils.ArrayUtils;
import com.gitblit.utils.DiffUtils;
import com.gitblit.utils.DiffUtils.DiffStat;
import com.gitblit.utils.JGitUtils;
import com.gitblit.utils.MarkdownUtils;
import com.gitblit.utils.StringUtils;

/**
 * Formats and queues ticket/patch notifications for dispatch to the
 * mail executor upon completion of a push or a ticket update.  Messages are
 * created as Markdown and then transformed to html.
 *
 * @author James Moger
 *
 */
public class TicketNotifier {

    protected final Map<Long, Mailing> queue = new TreeMap<Long, Mailing>();

    private final String SOFT_BRK = "\n";

    private final String HARD_BRK = "\n\n";

    private final String HR = "----\n\n";

    private final IStoredSettings settings;

    private final INotificationManager notificationManager;

    private final IUserManager userManager;

    private final IRepositoryManager repositoryManager;

    private final ITicketService ticketService;

    private final String addPattern = "<span style=\"color:darkgreen;\">+{0}</span>";
    private final String delPattern = "<span style=\"color:darkred;\">-{0}</span>";

    public TicketNotifier(IRuntimeManager runtimeManager, INotificationManager notificationManager,
            IUserManager userManager, IRepositoryManager repositoryManager, ITicketService ticketService) {

        this.settings = runtimeManager.getSettings();
        this.notificationManager = notificationManager;
        this.userManager = userManager;
        this.repositoryManager = repositoryManager;
        this.ticketService = ticketService;
    }

    public void sendAll() {
        for (Mailing mail : queue.values()) {
            notificationManager.send(mail);
        }
    }

    public void sendMailing(TicketModel ticket) {
        queueMailing(ticket);
        sendAll();
    }

    /**
     * Queues an update notification.
     *
     * @param ticket
     * @return a notification object used for testing
     */
    public Mailing queueMailing(TicketModel ticket) {
        try {
            // format notification message
            String markdown = formatLastChange(ticket);

            StringBuilder html = new StringBuilder();
            html.append("<head>");
            html.append(readStyle());
            html.append(readViewTicketAction(ticket));
            html.append("</head>");
            html.append("<body>");
            html.append(MarkdownUtils.transformGFM(settings, markdown, ticket.repository));
            html.append("</body>");

            Mailing mailing = Mailing.newHtml();
            mailing.from = getUserModel(ticket.updatedBy == null ? ticket.createdBy : ticket.updatedBy)
                    .getDisplayName();
            mailing.subject = getSubject(ticket);
            mailing.content = html.toString();
            mailing.id = "ticket." + ticket.number + "." + StringUtils.getSHA1(ticket.repository + ticket.number);

            setRecipients(ticket, mailing);
            queue.put(ticket.number, mailing);

            return mailing;
        } catch (Exception e) {
            Logger.getLogger(getClass()).error("failed to queue mailing for #" + ticket.number, e);
        }
        return null;
    }

    protected String getSubject(TicketModel ticket) {
        Change lastChange = ticket.changes.get(ticket.changes.size() - 1);
        boolean newTicket = lastChange.isStatusChange() && ticket.changes.size() == 1;
        String re = newTicket ? "" : "Re: ";
        String subject = MessageFormat.format("{0}[{1}] {2} (#{3,number,0})", re,
                StringUtils.stripDotGit(ticket.repository), ticket.title, ticket.number);
        return subject;
    }

    protected String formatLastChange(TicketModel ticket) {
        Change lastChange = ticket.changes.get(ticket.changes.size() - 1);
        UserModel user = getUserModel(lastChange.author);

        // define the fields we do NOT want to see in an email notification
        Set<TicketModel.Field> fieldExclusions = new HashSet<TicketModel.Field>();
        fieldExclusions.addAll(Arrays.asList(Field.watchers, Field.voters));

        StringBuilder sb = new StringBuilder();
        boolean newTicket = lastChange.isStatusChange() && Status.New == lastChange.getStatus();
        boolean isFastForward = true;
        List<RevCommit> commits = null;
        DiffStat diffstat = null;

        String pattern;
        if (lastChange.hasPatchset()) {
            // patchset uploaded
            Patchset patchset = lastChange.patchset;
            String base = "";
            // determine the changed paths
            Repository repo = null;
            try {
                repo = repositoryManager.getRepository(ticket.repository);
                if (patchset.isFF() && (patchset.rev > 1)) {
                    // fast-forward update, just show the new data
                    isFastForward = true;
                    Patchset prev = ticket.getPatchset(patchset.number, patchset.rev - 1);
                    base = prev.tip;
                } else {
                    // proposal OR non-fast-forward update
                    isFastForward = false;
                    base = patchset.base;
                }

                diffstat = DiffUtils.getDiffStat(repo, base, patchset.tip);
                commits = JGitUtils.getRevLog(repo, base, patchset.tip);
            } catch (Exception e) {
                Logger.getLogger(getClass()).error("failed to get changed paths", e);
            } finally {
                if (repo != null) {
                    repo.close();
                }
            }

            String compareUrl = ticketService.getCompareUrl(ticket, base, patchset.tip);

            if (newTicket) {
                // new proposal
                pattern = "**{0}** is proposing a change.";
                sb.append(MessageFormat.format(pattern, user.getDisplayName()));
                fieldExclusions.add(Field.status);
                fieldExclusions.add(Field.title);
                fieldExclusions.add(Field.body);
            } else {
                // describe the patchset
                if (patchset.isFF()) {
                    pattern = "**{0}** added {1} {2} to patchset {3}.";
                    sb.append(MessageFormat.format(pattern, user.getDisplayName(), patchset.added,
                            patchset.added == 1 ? "commit" : "commits", patchset.number));
                } else {
                    pattern = "**{0}** uploaded patchset {1}. *({2})*";
                    sb.append(MessageFormat.format(pattern, user.getDisplayName(), patchset.number,
                            patchset.type.toString().toUpperCase()));
                }
            }
            sb.append(HARD_BRK);

            sb.append(MessageFormat.format(
                    "{0} {1}, {2} {3}, <span style=\"color:darkgreen;\">+{4} insertions</span>, <span style=\"color:darkred;\">-{5} deletions</span> from {6}. [compare]({7})",
                    commits.size(), commits.size() == 1 ? "commit" : "commits", diffstat.paths.size(),
                    diffstat.paths.size() == 1 ? "file" : "files", diffstat.getInsertions(),
                    diffstat.getDeletions(), isFastForward ? "previous revision" : "merge base", compareUrl));

            // note commit additions on a rebase,if any
            switch (lastChange.patchset.type) {
            case Rebase:
                if (lastChange.patchset.added > 0) {
                    sb.append(SOFT_BRK);
                    sb.append(MessageFormat.format("{0} {1} added.", lastChange.patchset.added,
                            lastChange.patchset.added == 1 ? "commit" : "commits"));
                }
                break;
            default:
                break;
            }
            sb.append(HARD_BRK);
        } else if (lastChange.isStatusChange()) {
            if (newTicket) {
                fieldExclusions.add(Field.status);
                fieldExclusions.add(Field.title);
                fieldExclusions.add(Field.body);
                pattern = "**{0}** created this ticket.";
                sb.append(MessageFormat.format(pattern, user.getDisplayName()));
            } else if (lastChange.hasField(Field.mergeSha)) {
                // closed by merged
                pattern = "**{0}** closed this ticket by merging {1} to {2}.";

                // identify patchset that closed the ticket
                String merged = ticket.mergeSha;
                for (Patchset patchset : ticket.getPatchsets()) {
                    if (patchset.tip.equals(ticket.mergeSha)) {
                        merged = patchset.toString();
                        break;
                    }
                }
                sb.append(MessageFormat.format(pattern, user.getDisplayName(), merged, ticket.mergeTo));
            } else {
                // workflow status change by user
                pattern = "**{0}** changed the status of this ticket to **{1}**.";
                sb.append(MessageFormat.format(pattern, user.getDisplayName(),
                        lastChange.getStatus().toString().toUpperCase()));
            }
            sb.append(HARD_BRK);
        } else if (lastChange.hasReview()) {
            // review
            Review review = lastChange.review;
            pattern = "**{0}** has reviewed patchset {1,number,0} revision {2,number,0}.";
            sb.append(MessageFormat.format(pattern, user.getDisplayName(), review.patchset, review.rev));
            sb.append(HARD_BRK);

            String d = settings.getString(Keys.web.datestampShortFormat, "yyyy-MM-dd");
            String t = settings.getString(Keys.web.timeFormat, "HH:mm");
            DateFormat df = new SimpleDateFormat(d + " " + t);
            List<Change> reviews = ticket.getReviews(ticket.getPatchset(review.patchset, review.rev));
            sb.append("| Date | Reviewer      | Score | Description  |\n");
            sb.append("| :--- | :------------ | :---: | :----------- |\n");
            for (Change change : reviews) {
                String name = change.author;
                UserModel u = userManager.getUserModel(change.author);
                if (u != null) {
                    name = u.getDisplayName();
                }
                String score;
                switch (change.review.score) {
                case approved:
                    score = MessageFormat.format(addPattern, change.review.score.getValue());
                    break;
                case vetoed:
                    score = MessageFormat.format(delPattern, Math.abs(change.review.score.getValue()));
                    break;
                default:
                    score = "" + change.review.score.getValue();
                }
                String date = df.format(change.date);
                sb.append(String.format("| %1$s | %2$s | %3$s | %4$s |\n", date, name, score,
                        change.review.score.toString()));
            }
            sb.append(HARD_BRK);
        } else if (lastChange.hasComment()) {
            // comment update
            sb.append(MessageFormat.format("**{0}** commented on this ticket.", user.getDisplayName()));
            sb.append(HARD_BRK);
        } else {
            // general update
            pattern = "**{0}** has updated this ticket.";
            sb.append(MessageFormat.format(pattern, user.getDisplayName()));
            sb.append(HARD_BRK);
        }

        // ticket link
        sb.append(MessageFormat.format("[view ticket {0,number,0}]({1})", ticket.number,
                ticketService.getTicketUrl(ticket)));
        sb.append(HARD_BRK);

        if (newTicket) {
            // ticket title
            sb.append(MessageFormat.format("### {0}", ticket.title));
            sb.append(HARD_BRK);

            // ticket description, on state change
            if (StringUtils.isEmpty(ticket.body)) {
                sb.append("<span style=\"color: #888;\">no description entered</span>");
            } else {
                sb.append(ticket.body);
            }
            sb.append(HARD_BRK);
            sb.append(HR);
        }

        // field changes
        if (lastChange.hasFieldChanges()) {
            Map<Field, String> filtered = new HashMap<Field, String>();
            for (Map.Entry<Field, String> fc : lastChange.fields.entrySet()) {
                if (!fieldExclusions.contains(fc.getKey())) {
                    // field is included
                    filtered.put(fc.getKey(), fc.getValue());
                }
            }

            // sort by field ordinal
            List<Field> fields = new ArrayList<Field>(filtered.keySet());
            Collections.sort(fields);

            if (filtered.size() > 0) {
                sb.append(HARD_BRK);
                sb.append("| Field Changes               ||\n");
                sb.append("| ------------: | :----------- |\n");
                for (Field field : fields) {
                    String value;
                    if (filtered.get(field) == null) {
                        value = "";
                    } else {
                        value = filtered.get(field).replace("\r\n", "<br/>").replace("\n", "<br/>").replace("|",
                                "&#124;");
                    }
                    sb.append(String.format("| **%1$s:** | %2$s |\n", field.name(), value));
                }
                sb.append(HARD_BRK);
            }
        }

        // new comment
        if (lastChange.hasComment()) {
            sb.append(HR);
            sb.append(lastChange.comment.text);
            sb.append(HARD_BRK);
        }

        // insert the patchset details and review instructions
        if (lastChange.hasPatchset() && ticket.isOpen()) {
            if (commits != null && commits.size() > 0) {
                // append the commit list
                String title = isFastForward ? "Commits added to previous patchset revision"
                        : "All commits in patchset";
                sb.append(MessageFormat.format("| {0} |||\n", title));
                sb.append("| SHA | Author | Title |\n");
                sb.append("| :-- | :----- | :---- |\n");
                for (RevCommit commit : commits) {
                    sb.append(MessageFormat.format("| {0} | {1} | {2} |\n", commit.getName(),
                            commit.getAuthorIdent().getName(),
                            StringUtils.trimString(commit.getShortMessage(), Constants.LEN_SHORTLOG).replace("|",
                                    "&#124;")));
                }
                sb.append(HARD_BRK);
            }

            if (diffstat != null) {
                // append the changed path list
                String title = isFastForward ? "Files changed since previous patchset revision"
                        : "All files changed in patchset";
                sb.append(MessageFormat.format("| {0} |||\n", title));
                sb.append("| :-- | :----------- | :-: |\n");
                for (PathChangeModel path : diffstat.paths) {
                    String add = MessageFormat.format(addPattern, path.insertions);
                    String del = MessageFormat.format(delPattern, path.deletions);
                    String diff = null;
                    switch (path.changeType) {
                    case ADD:
                        diff = add;
                        break;
                    case DELETE:
                        diff = del;
                        break;
                    case MODIFY:
                        if (path.insertions > 0 && path.deletions > 0) {
                            // insertions & deletions
                            diff = add + "/" + del;
                        } else if (path.insertions > 0) {
                            // just insertions
                            diff = add;
                        } else {
                            // just deletions
                            diff = del;
                        }
                        break;
                    default:
                        diff = path.changeType.name();
                        break;
                    }
                    sb.append(MessageFormat.format("| {0} | {1} | {2} |\n", getChangeType(path.changeType),
                            path.name, diff));
                }
                sb.append(HARD_BRK);
            }

            sb.append(formatPatchsetInstructions(ticket, lastChange.patchset));
        }

        return sb.toString();
    }

    protected String getChangeType(ChangeType type) {
        String style = null;
        switch (type) {
        case ADD:
            style = "color:darkgreen;";
            break;
        case COPY:
            style = "";
            break;
        case DELETE:
            style = "color:darkred;";
            break;
        case MODIFY:
            style = "";
            break;
        case RENAME:
            style = "";
            break;
        default:
            break;
        }
        String code = type.name().toUpperCase().substring(0, 1);
        if (style == null) {
            return code;
        } else {
            return MessageFormat.format(
                    "<strong><span style=\"{0}padding:2px;margin:2px;border:1px solid #ddd;\">{1}</span></strong>",
                    style, code);
        }
    }

    /**
     * Generates patchset review instructions for command-line git
     *
     * @param patchset
     * @return instructions
     */
    protected String formatPatchsetInstructions(TicketModel ticket, Patchset patchset) {
        String canonicalUrl = settings.getString(Keys.web.canonicalUrl, "https://localhost:8443");
        String repositoryUrl = canonicalUrl + Constants.R_PATH + ticket.repository;

        String ticketBranch = Repository.shortenRefName(PatchsetCommand.getTicketBranch(ticket.number));
        String patchsetBranch = PatchsetCommand.getPatchsetBranch(ticket.number, patchset.number);
        String reviewBranch = PatchsetCommand.getReviewBranch(ticket.number);

        String instructions = readResource("commands.md");
        instructions = instructions.replace("${ticketId}", "" + ticket.number);
        instructions = instructions.replace("${patchset}", "" + patchset.number);
        instructions = instructions.replace("${repositoryUrl}", repositoryUrl);
        instructions = instructions.replace("${ticketRef}", ticketBranch);
        instructions = instructions.replace("${patchsetRef}", patchsetBranch);
        instructions = instructions.replace("${reviewBranch}", reviewBranch);
        instructions = instructions.replace("${ticketBranch}", ticketBranch);

        return instructions;
    }

    /**
     * Gets the usermodel for the username.  Creates a temp model, if required.
     *
     * @param username
     * @return a usermodel
     */
    protected UserModel getUserModel(String username) {
        UserModel user = userManager.getUserModel(username);
        if (user == null) {
            // create a temporary user model (for unit tests)
            user = new UserModel(username);
        }
        return user;
    }

    /**
     * Set the proper recipients for a ticket.
     *
     * @param ticket
     * @param mailing
     */
    protected void setRecipients(TicketModel ticket, Mailing mailing) {
        RepositoryModel repository = repositoryManager.getRepositoryModel(ticket.repository);

        //
        // Direct TO recipients
        // reporter & responsible
        //
        Set<String> tos = new TreeSet<String>();
        tos.add(ticket.createdBy);
        if (!StringUtils.isEmpty(ticket.responsible)) {
            tos.add(ticket.responsible);
        }

        Set<String> toAddresses = new TreeSet<String>();
        for (String name : tos) {
            UserModel user = userManager.getUserModel(name);
            if (user != null && !user.disabled) {
                if (!StringUtils.isEmpty(user.emailAddress)) {
                    if (user.canView(repository)) {
                        toAddresses.add(user.emailAddress);
                    } else {
                        LoggerFactory.getLogger(getClass())
                                .warn(MessageFormat.format(
                                        "ticket {0}-{1,number,0}: {2} can not receive notification",
                                        repository.name, ticket.number, user.username));
                    }
                }
            }
        }

        //
        // CC recipients
        //
        Set<String> ccs = new TreeSet<String>();

        // repository owners
        if (!ArrayUtils.isEmpty(repository.owners)) {
            ccs.addAll(repository.owners);
        }

        // cc users mentioned in last comment
        Change lastChange = ticket.changes.get(ticket.changes.size() - 1);
        if (lastChange.hasComment()) {
            Pattern p = Pattern.compile("\\s@([A-Za-z0-9-_]+)");
            Matcher m = p.matcher(lastChange.comment.text);
            while (m.find()) {
                String username = m.group();
                ccs.add(username);
            }
        }

        // cc users who are watching the ticket
        ccs.addAll(ticket.getWatchers());

        // TODO cc users who are watching the repository

        Set<String> ccAddresses = new TreeSet<String>();
        for (String name : ccs) {
            UserModel user = userManager.getUserModel(name);
            if (user != null && !user.disabled) {
                if (!StringUtils.isEmpty(user.emailAddress)) {
                    if (user.canView(repository)) {
                        ccAddresses.add(user.emailAddress);
                    } else {
                        LoggerFactory.getLogger(getClass())
                                .warn(MessageFormat.format(
                                        "ticket {0}-{1,number,0}: {2} can not receive notification",
                                        repository.name, ticket.number, user.username));
                    }
                }
            }
        }

        // cc repository mailing list addresses
        if (!ArrayUtils.isEmpty(repository.mailingLists)) {
            ccAddresses.addAll(repository.mailingLists);
        }
        ccAddresses.addAll(settings.getStrings(Keys.mail.mailingLists));

        // respect the author's email preference
        UserModel lastAuthor = userManager.getUserModel(lastChange.author);
        if (lastAuthor != null && !lastAuthor.getPreferences().isEmailMeOnMyTicketChanges()) {
            toAddresses.remove(lastAuthor.emailAddress);
            ccAddresses.remove(lastAuthor.emailAddress);
        }

        mailing.setRecipients(toAddresses);
        mailing.setCCs(ccAddresses);
    }

    protected String readStyle() {
        StringBuilder sb = new StringBuilder();
        sb.append("<style>\n");
        sb.append(readResource("email.css"));
        sb.append("</style>\n");
        return sb.toString();
    }

    protected String readViewTicketAction(TicketModel ticket) {
        String action = readResource("viewTicket.html");
        action = action.replace("${url}", ticketService.getTicketUrl(ticket));
        return action;
    }

    protected String readResource(String resource) {
        StringBuilder sb = new StringBuilder();
        InputStream is = null;
        try {
            is = getClass().getResourceAsStream(resource);
            List<String> lines = IOUtils.readLines(is);
            for (String line : lines) {
                sb.append(line).append('\n');
            }
        } catch (IOException e) {

        } finally {
            if (is != null) {
                try {
                    is.close();
                } catch (IOException e) {
                }
            }
        }
        return sb.toString();
    }
}