diff options
Diffstat (limited to 'java/com/google/gerrit/server/mail/send/CommentSender.java')
-rw-r--r-- | java/com/google/gerrit/server/mail/send/CommentSender.java | 575 |
1 files changed, 575 insertions, 0 deletions
diff --git a/java/com/google/gerrit/server/mail/send/CommentSender.java b/java/com/google/gerrit/server/mail/send/CommentSender.java new file mode 100644 index 0000000000..69a79266ec --- /dev/null +++ b/java/com/google/gerrit/server/mail/send/CommentSender.java @@ -0,0 +1,575 @@ +// Copyright (C) 2016 The Android Open Source Project +// +// 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.google.gerrit.server.mail.send; + +import static java.util.stream.Collectors.toList; + +import com.google.common.base.Strings; +import com.google.common.collect.Ordering; +import com.google.common.flogger.FluentLogger; +import com.google.gerrit.common.data.FilenameComparator; +import com.google.gerrit.common.errors.EmailException; +import com.google.gerrit.common.errors.NoSuchEntityException; +import com.google.gerrit.extensions.api.changes.NotifyHandling; +import com.google.gerrit.mail.MailHeader; +import com.google.gerrit.mail.MailProcessingUtil; +import com.google.gerrit.reviewdb.client.Account; +import com.google.gerrit.reviewdb.client.Change; +import com.google.gerrit.reviewdb.client.Comment; +import com.google.gerrit.reviewdb.client.Patch; +import com.google.gerrit.reviewdb.client.Project; +import com.google.gerrit.reviewdb.client.RobotComment; +import com.google.gerrit.server.CommentsUtil; +import com.google.gerrit.server.account.ProjectWatches.NotifyType; +import com.google.gerrit.server.config.GerritServerConfig; +import com.google.gerrit.server.mail.receive.Protocol; +import com.google.gerrit.server.patch.PatchFile; +import com.google.gerrit.server.patch.PatchList; +import com.google.gerrit.server.patch.PatchListNotAvailableException; +import com.google.gerrit.server.patch.PatchListObjectTooLargeException; +import com.google.gerrit.server.util.LabelVote; +import com.google.gwtorm.client.KeyUtil; +import com.google.gwtorm.server.OrmException; +import com.google.inject.Inject; +import com.google.inject.assistedinject.Assisted; +import java.io.IOException; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import org.apache.james.mime4j.dom.field.FieldName; +import org.eclipse.jgit.lib.Config; +import org.eclipse.jgit.lib.Repository; + +/** Send comments, after the author of them hit used Publish Comments in the UI. */ +public class CommentSender extends ReplyToChangeSender { + private static final FluentLogger logger = FluentLogger.forEnclosingClass(); + + public interface Factory { + CommentSender create(Project.NameKey project, Change.Id id); + } + + private class FileCommentGroup { + public String filename; + public int patchSetId; + public PatchFile fileData; + public List<Comment> comments = new ArrayList<>(); + + /** @return a web link to the given patch set and file. */ + public String getLink() { + String url = getGerritUrl(); + if (url == null) { + return null; + } + + return new StringBuilder() + .append(url) + .append("#/c/") + .append(change.getId()) + .append('/') + .append(patchSetId) + .append('/') + .append(KeyUtil.encode(filename)) + .toString(); + } + + /** + * @return A title for the group, i.e. "Commit Message", "Merge List", or "File [[filename]]". + */ + public String getTitle() { + if (Patch.COMMIT_MSG.equals(filename)) { + return "Commit Message"; + } else if (Patch.MERGE_LIST.equals(filename)) { + return "Merge List"; + } else { + return "File " + filename; + } + } + } + + private List<Comment> inlineComments = Collections.emptyList(); + private String patchSetComment; + private List<LabelVote> labels = Collections.emptyList(); + private final CommentsUtil commentsUtil; + private final boolean incomingEmailEnabled; + private final String replyToAddress; + + @Inject + public CommentSender( + EmailArguments ea, + CommentsUtil commentsUtil, + @GerritServerConfig Config cfg, + @Assisted Project.NameKey project, + @Assisted Change.Id id) + throws OrmException { + super(ea, "comment", newChangeData(ea, project, id)); + this.commentsUtil = commentsUtil; + this.incomingEmailEnabled = + cfg.getEnum("receiveemail", null, "protocol", Protocol.NONE).ordinal() + > Protocol.NONE.ordinal(); + this.replyToAddress = cfg.getString("sendemail", null, "replyToAddress"); + } + + public void setComments(List<Comment> comments) throws OrmException { + inlineComments = comments; + + Set<String> paths = new HashSet<>(); + for (Comment c : comments) { + if (!Patch.isMagic(c.key.filename)) { + paths.add(c.key.filename); + } + } + changeData.setCurrentFilePaths(Ordering.natural().sortedCopy(paths)); + } + + public void setPatchSetComment(String comment) { + this.patchSetComment = comment; + } + + public void setLabels(List<LabelVote> labels) { + this.labels = labels; + } + + @Override + protected void init() throws EmailException { + super.init(); + + if (notify.compareTo(NotifyHandling.OWNER_REVIEWERS) >= 0) { + ccAllApprovals(); + } + if (notify.compareTo(NotifyHandling.ALL) >= 0) { + bccStarredBy(); + includeWatchers(NotifyType.ALL_COMMENTS, !change.isWorkInProgress() && !change.isPrivate()); + } + removeUsersThatIgnoredTheChange(); + + // Add header that enables identifying comments on parsed email. + // Grouping is currently done by timestamp. + setHeader(MailHeader.COMMENT_DATE.fieldName(), timestamp); + + if (incomingEmailEnabled) { + if (replyToAddress == null) { + // Remove Reply-To and use outbound SMTP (default) instead. + removeHeader(FieldName.REPLY_TO); + } else { + setHeader(FieldName.REPLY_TO, replyToAddress); + } + } + } + + @Override + public void formatChange() throws EmailException { + appendText(textTemplate("Comment")); + if (useHtml()) { + appendHtml(soyHtmlTemplate("CommentHtml")); + } + } + + @Override + public void formatFooter() throws EmailException { + appendText(textTemplate("CommentFooter")); + if (useHtml()) { + appendHtml(soyHtmlTemplate("CommentFooterHtml")); + } + } + + /** + * @return a list of FileCommentGroup objects representing the inline comments grouped by the + * file. + */ + private List<CommentSender.FileCommentGroup> getGroupedInlineComments(Repository repo) { + List<CommentSender.FileCommentGroup> groups = new ArrayList<>(); + + // Loop over the comments and collect them into groups based on the file + // location of the comment. + FileCommentGroup currentGroup = null; + for (Comment c : inlineComments) { + // If it's a new group: + if (currentGroup == null + || !c.key.filename.equals(currentGroup.filename) + || c.key.patchSetId != currentGroup.patchSetId) { + currentGroup = new FileCommentGroup(); + currentGroup.filename = c.key.filename; + currentGroup.patchSetId = c.key.patchSetId; + // Get the patch list: + PatchList patchList = null; + try { + patchList = getPatchList(c.key.patchSetId); + } catch (PatchListObjectTooLargeException e) { + logger.atWarning().log("Failed to get patch list: %s", e.getMessage()); + } catch (PatchListNotAvailableException e) { + logger.atSevere().withCause(e).log("Failed to get patch list"); + } + + groups.add(currentGroup); + if (patchList != null) { + try { + currentGroup.fileData = new PatchFile(repo, patchList, c.key.filename); + } catch (IOException e) { + logger.atWarning().withCause(e).log( + "Cannot load %s from %s in %s", + c.key.filename, patchList.getNewId().name(), projectState.getName()); + currentGroup.fileData = null; + } + } + } + + if (currentGroup.fileData != null) { + currentGroup.comments.add(c); + } + } + + groups.sort(Comparator.comparing(g -> g.filename, FilenameComparator.INSTANCE)); + return groups; + } + + /** Get the set of accounts whose comments have been replied to in this email. */ + private HashSet<Account.Id> getReplyAccounts() { + HashSet<Account.Id> replyAccounts = new HashSet<>(); + + // Track visited parent UUIDs to avoid cycles. + HashSet<String> visitedUuids = new HashSet<>(); + + for (Comment comment : inlineComments) { + visitedUuids.add(comment.key.uuid); + + // Traverse the parent relation to the top of the comment thread. + Comment current = comment; + while (current.parentUuid != null && !visitedUuids.contains(current.parentUuid)) { + Optional<Comment> optParent = getParent(current); + if (!optParent.isPresent()) { + // There is a parent UUID, but it cannot be loaded, break from the comment thread. + break; + } + + Comment parent = optParent.get(); + replyAccounts.add(parent.author.getId()); + visitedUuids.add(current.parentUuid); + current = parent; + } + } + return replyAccounts; + } + + private String getCommentLinePrefix(Comment comment) { + int lineNbr = comment.range == null ? comment.lineNbr : comment.range.startLine; + StringBuilder sb = new StringBuilder(); + sb.append("PS").append(comment.key.patchSetId); + if (lineNbr != 0) { + sb.append(", Line ").append(lineNbr); + } + sb.append(": "); + return sb.toString(); + } + + /** + * @return the lines of file content in fileData that are encompassed by range on the given side. + */ + private List<String> getLinesByRange(Comment.Range range, PatchFile fileData, short side) { + List<String> lines = new ArrayList<>(); + + for (int n = range.startLine; n <= range.endLine; n++) { + String s = getLine(fileData, side, n); + if (n == range.startLine && n == range.endLine && range.startChar < range.endChar) { + s = s.substring(Math.min(range.startChar, s.length()), Math.min(range.endChar, s.length())); + } else if (n == range.startLine) { + s = s.substring(Math.min(range.startChar, s.length())); + } else if (n == range.endLine) { + s = s.substring(0, Math.min(range.endChar, s.length())); + } + lines.add(s); + } + return lines; + } + + /** + * Get the parent comment of a given comment. + * + * @param child the comment with a potential parent comment. + * @return an optional comment that will be present if the given comment has a parent, and is + * empty if it does not. + */ + private Optional<Comment> getParent(Comment child) { + if (child.parentUuid == null) { + return Optional.empty(); + } + + Comment.Key key = new Comment.Key(child.parentUuid, child.key.filename, child.key.patchSetId); + try { + return commentsUtil.getPublished(args.db.get(), changeData.notes(), key); + } catch (OrmException e) { + logger.atWarning().log("Could not find the parent of this comment: %s", child); + return Optional.empty(); + } + } + + /** + * Retrieve the file lines referred to by a comment. + * + * @param comment The comment that refers to some file contents. The comment may be a line comment + * or a ranged comment. + * @param fileData The file on which the comment appears. + * @return file contents referred to by the comment. If the comment is a line comment, the result + * will be a list of one string. Otherwise it will be a list of one or more strings. + */ + private List<String> getLinesOfComment(Comment comment, PatchFile fileData) { + List<String> lines = new ArrayList<>(); + if (comment.lineNbr == 0) { + // file level comment has no line + return lines; + } + if (comment.range == null) { + lines.add(getLine(fileData, comment.side, comment.lineNbr)); + } else { + lines.addAll(getLinesByRange(comment.range, fileData, comment.side)); + } + return lines; + } + + /** + * @return a shortened version of the given comment's message. Will be shortened to 100 characters + * or the first line, or following the last period within the first 100 characters, whichever + * is shorter. If the message is shortened, an ellipsis is appended. + */ + protected static String getShortenedCommentMessage(String message) { + int threshold = 100; + String fullMessage = message.trim(); + String msg = fullMessage; + + if (msg.length() > threshold) { + msg = msg.substring(0, threshold); + } + + int lf = msg.indexOf('\n'); + int period = msg.lastIndexOf('.'); + + if (lf > 0) { + // Truncate if a line feed appears within the threshold. + msg = msg.substring(0, lf); + + } else if (period > 0) { + // Otherwise truncate if there is a period within the threshold. + msg = msg.substring(0, period + 1); + } + + // Append an ellipsis if the message has been truncated. + if (!msg.equals(fullMessage)) { + msg += " […]"; + } + + return msg; + } + + protected static String getShortenedCommentMessage(Comment comment) { + return getShortenedCommentMessage(comment.message); + } + + /** + * @return grouped inline comment data mapped to data structures that are suitable for passing + * into Soy. + */ + private List<Map<String, Object>> getCommentGroupsTemplateData(Repository repo) { + List<Map<String, Object>> commentGroups = new ArrayList<>(); + + for (CommentSender.FileCommentGroup group : getGroupedInlineComments(repo)) { + Map<String, Object> groupData = new HashMap<>(); + groupData.put("link", group.getLink()); + groupData.put("title", group.getTitle()); + groupData.put("patchSetId", group.patchSetId); + + List<Map<String, Object>> commentsList = new ArrayList<>(); + for (Comment comment : group.comments) { + Map<String, Object> commentData = new HashMap<>(); + commentData.put("lines", getLinesOfComment(comment, group.fileData)); + commentData.put("message", comment.message.trim()); + List<CommentFormatter.Block> blocks = CommentFormatter.parse(comment.message); + commentData.put("messageBlocks", commentBlocksToSoyData(blocks)); + + // Set the prefix. + String prefix = getCommentLinePrefix(comment); + commentData.put("linePrefix", prefix); + commentData.put("linePrefixEmpty", Strings.padStart(": ", prefix.length(), ' ')); + + // Set line numbers. + int startLine; + if (comment.range == null) { + startLine = comment.lineNbr; + } else { + startLine = comment.range.startLine; + commentData.put("endLine", comment.range.endLine); + } + commentData.put("startLine", startLine); + + // Set the comment link. + if (comment.lineNbr == 0) { + commentData.put("link", group.getLink()); + } else if (comment.side == 0) { + commentData.put("link", group.getLink() + "@a" + startLine); + } else { + commentData.put("link", group.getLink() + '@' + startLine); + } + + // Set robot comment data. + if (comment instanceof RobotComment) { + RobotComment robotComment = (RobotComment) comment; + commentData.put("isRobotComment", true); + commentData.put("robotId", robotComment.robotId); + commentData.put("robotRunId", robotComment.robotRunId); + commentData.put("robotUrl", robotComment.url); + } else { + commentData.put("isRobotComment", false); + } + + // If the comment has a quote, don't bother loading the parent message. + if (!hasQuote(blocks)) { + // Set parent comment info. + Optional<Comment> parent = getParent(comment); + if (parent.isPresent()) { + commentData.put("parentMessage", getShortenedCommentMessage(parent.get())); + } + } + + commentsList.add(commentData); + } + groupData.put("comments", commentsList); + + commentGroups.add(groupData); + } + return commentGroups; + } + + private List<Map<String, Object>> commentBlocksToSoyData(List<CommentFormatter.Block> blocks) { + return blocks.stream() + .map( + b -> { + Map<String, Object> map = new HashMap<>(); + switch (b.type) { + case PARAGRAPH: + map.put("type", "paragraph"); + map.put("text", b.text); + break; + case PRE_FORMATTED: + map.put("type", "pre"); + map.put("text", b.text); + break; + case QUOTE: + map.put("type", "quote"); + map.put("quotedBlocks", commentBlocksToSoyData(b.quotedBlocks)); + break; + case LIST: + map.put("type", "list"); + map.put("items", b.items); + break; + } + return map; + }) + .collect(toList()); + } + + private boolean hasQuote(List<CommentFormatter.Block> blocks) { + for (CommentFormatter.Block block : blocks) { + if (block.type == CommentFormatter.BlockType.QUOTE) { + return true; + } + } + return false; + } + + private Repository getRepository() { + try { + return args.server.openRepository(projectState.getNameKey()); + } catch (IOException e) { + return null; + } + } + + @Override + protected void setupSoyContext() { + super.setupSoyContext(); + boolean hasComments; + try (Repository repo = getRepository()) { + List<Map<String, Object>> files = getCommentGroupsTemplateData(repo); + soyContext.put("commentFiles", files); + hasComments = !files.isEmpty(); + } + + soyContext.put( + "patchSetCommentBlocks", commentBlocksToSoyData(CommentFormatter.parse(patchSetComment))); + soyContext.put("labels", getLabelVoteSoyData(labels)); + soyContext.put("commentCount", inlineComments.size()); + soyContext.put("commentTimestamp", getCommentTimestamp()); + soyContext.put( + "coverLetterBlocks", commentBlocksToSoyData(CommentFormatter.parse(getCoverLetter()))); + + footers.add(MailHeader.COMMENT_DATE.withDelimiter() + getCommentTimestamp()); + footers.add(MailHeader.HAS_COMMENTS.withDelimiter() + (hasComments ? "Yes" : "No")); + footers.add(MailHeader.HAS_LABELS.withDelimiter() + (labels.isEmpty() ? "No" : "Yes")); + + for (Account.Id account : getReplyAccounts()) { + footers.add(MailHeader.COMMENT_IN_REPLY_TO.withDelimiter() + getNameEmailFor(account)); + } + } + + private String getLine(PatchFile fileInfo, short side, int lineNbr) { + try { + return fileInfo.getLine(side, lineNbr); + } catch (IOException err) { + // Default to the empty string if the file cannot be safely read. + logger.atWarning().withCause(err).log("Failed to read file on side %d", side); + return ""; + } catch (IndexOutOfBoundsException err) { + // Default to the empty string if the given line number does not appear + // in the file. + logger.atFine().withCause(err).log("Failed to get line number of file on side %d", side); + return ""; + } catch (NoSuchEntityException err) { + // Default to the empty string if the side cannot be found. + logger.atWarning().withCause(err).log("Side %d of file didn't exist", side); + return ""; + } + } + + private List<Map<String, Object>> getLabelVoteSoyData(List<LabelVote> votes) { + List<Map<String, Object>> result = new ArrayList<>(); + for (LabelVote vote : votes) { + Map<String, Object> data = new HashMap<>(); + data.put("label", vote.label()); + + // Soy needs the short to be cast as an int for it to get converted to the + // correct tamplate type. + data.put("value", (int) vote.value()); + result.add(data); + } + return result; + } + + private String getCommentTimestamp() { + // Grouping is currently done by timestamp. + return MailProcessingUtil.rfcDateformatter.format( + ZonedDateTime.ofInstant(timestamp.toInstant(), ZoneId.of("UTC"))); + } + + @Override + protected boolean supportsHtml() { + return true; + } +} |