/* * 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 queue = new TreeMap(); 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 = "+{0}"; private final String delPattern = "-{0}"; 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(""); html.append(readStyle()); html.append(readViewTicketAction(ticket)); html.append(""); html.append(""); html.append(MarkdownUtils.transformGFM(settings, markdown, ticket.repository)); html.append(""); 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 fieldExclusions = new HashSet(); fieldExclusions.addAll(Arrays.asList(Field.watchers, Field.voters)); StringBuilder sb = new StringBuilder(); boolean newTicket = lastChange.isStatusChange() && Status.New == lastChange.getStatus(); boolean isFastForward = true; List 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}, +{4} insertions, -{5} deletions 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 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 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."; 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("no description entered"); } else { sb.append(ticket.body); } sb.append(HARD_BRK); sb.append(HR); } // field changes if (lastChange.hasFieldChanges()) { Map filtered = new HashMap(); for (Map.Entry fc : lastChange.fields.entrySet()) { if (!fieldExclusions.contains(fc.getKey())) { // field is included filtered.put(fc.getKey(), fc.getValue()); } } // sort by field ordinal List fields = new ArrayList(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", "
").replace("\n", "
").replace("|", "|"); } 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("|", "|"))); } 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("{1}", 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 tos = new TreeSet(); tos.add(ticket.createdBy); if (!StringUtils.isEmpty(ticket.responsible)) { tos.add(ticket.responsible); } Set toAddresses = new TreeSet(); 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 ccs = new TreeSet(); // 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 ccAddresses = new TreeSet(); 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("\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 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(); } }