123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617 |
- /*
- * 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("</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 = false;
- boolean isFastForward = true;
- List<RevCommit> commits = null;
- DiffStat diffstat = null;
-
- String pattern;
- if (lastChange.isStatusChange()) {
- Status state = lastChange.getStatus();
- switch (state) {
- case New:
- // new ticket
- newTicket = true;
- fieldExclusions.add(Field.status);
- fieldExclusions.add(Field.title);
- fieldExclusions.add(Field.body);
- if (lastChange.hasPatchset()) {
- pattern = "**{0}** is proposing a change.";
- } else {
- pattern = "**{0}** created this ticket.";
- }
- sb.append(MessageFormat.format(pattern, user.getDisplayName()));
- break;
- default:
- // some form of resolved
- if (lastChange.hasField(Field.mergeSha)) {
- // closed by push (merged patchset)
- pattern = "**{0}** closed this ticket by pushing {1} to {2}.";
-
- // identify patch 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()));
- }
- break;
- }
- sb.append(HARD_BRK);
- } else 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 {
- repo.close();
- }
-
- // describe the patchset
- String compareUrl = ticketService.getCompareUrl(ticket, base, patchset.tip);
- 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.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("|", "|");
- }
- 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("<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);
-
- 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
- //
- Set<String> toAddresses = new TreeSet<String>();
- for (String name : ticket.getParticipants()) {
- UserModel user = userManager.getUserModel(name);
- if (user != null) {
- 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));
- }
- }
- }
- }
- mailing.setRecipients(toAddresses);
-
- //
- // CC recipients
- //
- Set<String> ccs = new TreeSet<String>();
-
- // 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) {
- 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));
-
- 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 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();
- }
- }
|