LCOV - code coverage report
Current view: top level - server/mail/send - CommentSender.java (source / functions) Hit Total Coverage
Test: _coverage_report.dat Lines: 260 282 92.2 %
Date: 2022-11-19 15:00:39 Functions: 37 37 100.0 %

          Line data    Source code
       1             : // Copyright (C) 2016 The Android Open Source Project
       2             : //
       3             : // Licensed under the Apache License, Version 2.0 (the "License");
       4             : // you may not use this file except in compliance with the License.
       5             : // You may obtain a copy of the License at
       6             : //
       7             : // http://www.apache.org/licenses/LICENSE-2.0
       8             : //
       9             : // Unless required by applicable law or agreed to in writing, software
      10             : // distributed under the License is distributed on an "AS IS" BASIS,
      11             : // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      12             : // See the License for the specific language governing permissions and
      13             : // limitations under the License.
      14             : 
      15             : package com.google.gerrit.server.mail.send;
      16             : 
      17             : import static com.google.common.collect.ImmutableList.toImmutableList;
      18             : import static com.google.gerrit.entities.Patch.PATCHSET_LEVEL;
      19             : import static java.util.stream.Collectors.toList;
      20             : 
      21             : import com.google.common.base.Strings;
      22             : import com.google.common.base.Supplier;
      23             : import com.google.common.base.Suppliers;
      24             : import com.google.common.collect.ImmutableList;
      25             : import com.google.common.flogger.FluentLogger;
      26             : import com.google.gerrit.common.Nullable;
      27             : import com.google.gerrit.common.data.FilenameComparator;
      28             : import com.google.gerrit.entities.Account;
      29             : import com.google.gerrit.entities.Change;
      30             : import com.google.gerrit.entities.Comment;
      31             : import com.google.gerrit.entities.HumanComment;
      32             : import com.google.gerrit.entities.NotifyConfig.NotifyType;
      33             : import com.google.gerrit.entities.Patch;
      34             : import com.google.gerrit.entities.Project;
      35             : import com.google.gerrit.entities.RobotComment;
      36             : import com.google.gerrit.entities.SubmitRequirement;
      37             : import com.google.gerrit.entities.SubmitRequirementResult;
      38             : import com.google.gerrit.exceptions.EmailException;
      39             : import com.google.gerrit.exceptions.NoSuchEntityException;
      40             : import com.google.gerrit.exceptions.StorageException;
      41             : import com.google.gerrit.extensions.api.changes.NotifyHandling;
      42             : import com.google.gerrit.mail.MailHeader;
      43             : import com.google.gerrit.mail.MailProcessingUtil;
      44             : import com.google.gerrit.server.CommentsUtil;
      45             : import com.google.gerrit.server.config.GerritServerConfig;
      46             : import com.google.gerrit.server.mail.receive.Protocol;
      47             : import com.google.gerrit.server.patch.PatchFile;
      48             : import com.google.gerrit.server.patch.filediff.FileDiffOutput;
      49             : import com.google.gerrit.server.util.LabelVote;
      50             : import com.google.inject.Inject;
      51             : import com.google.inject.assistedinject.Assisted;
      52             : import java.io.IOException;
      53             : import java.time.ZoneId;
      54             : import java.time.ZonedDateTime;
      55             : import java.util.ArrayList;
      56             : import java.util.Collections;
      57             : import java.util.Comparator;
      58             : import java.util.HashMap;
      59             : import java.util.HashSet;
      60             : import java.util.List;
      61             : import java.util.Map;
      62             : import java.util.Optional;
      63             : import org.apache.james.mime4j.dom.field.FieldName;
      64             : import org.eclipse.jgit.lib.Config;
      65             : import org.eclipse.jgit.lib.ObjectId;
      66             : import org.eclipse.jgit.lib.Repository;
      67             : 
      68             : /** Send comments, after the author of them hit used Publish Comments in the UI. */
      69             : public class CommentSender extends ReplyToChangeSender {
      70             : 
      71          66 :   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
      72             : 
      73             :   public interface Factory {
      74             : 
      75             :     CommentSender create(
      76             :         Project.NameKey project,
      77             :         Change.Id changeId,
      78             :         ObjectId preUpdateMetaId,
      79             :         Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults);
      80             :   }
      81             : 
      82          23 :   private class FileCommentGroup {
      83             : 
      84             :     public String filename;
      85             :     public int patchSetId;
      86             :     public PatchFile fileData;
      87          23 :     public List<Comment> comments = new ArrayList<>();
      88             : 
      89             :     /** Returns a web link to a comment for a change. */
      90             :     @Nullable
      91             :     public String getCommentLink(String uuid) {
      92          22 :       return args.urlFormatter.get().getInlineCommentView(change, uuid).orElse(null);
      93             :     }
      94             : 
      95             :     /** Returns a web link to the comment tab view of a change. */
      96             :     @Nullable
      97             :     public String getCommentsTabLink() {
      98           4 :       return args.urlFormatter.get().getCommentsTabView(change).orElse(null);
      99             :     }
     100             : 
     101             :     /** Returns a web link to the findings tab view of a change. */
     102             :     @Nullable
     103             :     public String getFindingsTabLink() {
     104           1 :       return args.urlFormatter.get().getFindingsTabView(change).orElse(null);
     105             :     }
     106             : 
     107             :     /**
     108             :      * Returns a title for the group, i.e. "Commit Message", "Merge List", or "File [[filename]]".
     109             :      */
     110             :     public String getTitle() {
     111          23 :       if (Patch.COMMIT_MSG.equals(filename)) {
     112          10 :         return "Commit Message";
     113          17 :       } else if (Patch.MERGE_LIST.equals(filename)) {
     114           1 :         return "Merge List";
     115          17 :       } else if (Patch.PATCHSET_LEVEL.equals(filename)) {
     116           5 :         return "Patchset";
     117             :       } else {
     118          16 :         return "File " + filename;
     119             :       }
     120             :     }
     121             :   }
     122             : 
     123          65 :   private List<? extends Comment> inlineComments = Collections.emptyList();
     124             :   @Nullable private String patchSetComment;
     125          65 :   private ImmutableList<LabelVote> labels = ImmutableList.of();
     126             :   private final CommentsUtil commentsUtil;
     127             :   private final boolean incomingEmailEnabled;
     128             :   private final String replyToAddress;
     129             :   private final Supplier<Map<SubmitRequirement, SubmitRequirementResult>>
     130             :       preUpdateSubmitRequirementResultsSupplier;
     131             :   private final Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults;
     132             : 
     133             :   @Inject
     134             :   public CommentSender(
     135             :       EmailArguments args,
     136             :       CommentsUtil commentsUtil,
     137             :       @GerritServerConfig Config cfg,
     138             :       @Assisted Project.NameKey project,
     139             :       @Assisted Change.Id changeId,
     140             :       @Assisted ObjectId preUpdateMetaId,
     141             :       @Assisted
     142             :           Map<SubmitRequirement, SubmitRequirementResult> postUpdateSubmitRequirementResults) {
     143          65 :     super(args, "comment", newChangeData(args, project, changeId));
     144          65 :     this.commentsUtil = commentsUtil;
     145          65 :     this.incomingEmailEnabled =
     146          65 :         cfg.getEnum("receiveemail", null, "protocol", Protocol.NONE).ordinal()
     147          65 :             > Protocol.NONE.ordinal();
     148          65 :     this.replyToAddress = cfg.getString("sendemail", null, "replyToAddress");
     149          65 :     this.preUpdateSubmitRequirementResultsSupplier =
     150          65 :         Suppliers.memoize(
     151             :             () ->
     152             :                 // Triggers an (expensive) evaluation of the submit requirements. This is OK since
     153             :                 // all callers sent this email asynchronously, see EmailReviewComments.
     154          65 :                 newChangeData(args, project, changeId, preUpdateMetaId)
     155          65 :                     .submitRequirementsIncludingLegacy());
     156          65 :     this.postUpdateSubmitRequirementResults = postUpdateSubmitRequirementResults;
     157          65 :   }
     158             : 
     159             :   public void setComments(List<? extends Comment> comments) {
     160          65 :     inlineComments = comments;
     161          65 :   }
     162             : 
     163             :   public void setPatchSetComment(@Nullable String comment) {
     164          65 :     this.patchSetComment = comment;
     165          65 :   }
     166             : 
     167             :   public void setLabels(ImmutableList<LabelVote> labels) {
     168          65 :     this.labels = labels;
     169          65 :   }
     170             : 
     171             :   @Override
     172             :   protected void init() throws EmailException {
     173          65 :     super.init();
     174             : 
     175          65 :     if (notify.handling().compareTo(NotifyHandling.OWNER_REVIEWERS) >= 0) {
     176          65 :       ccAllApprovals();
     177             :     }
     178          65 :     if (notify.handling().compareTo(NotifyHandling.ALL) >= 0) {
     179          64 :       bccStarredBy();
     180          64 :       includeWatchers(NotifyType.ALL_COMMENTS, !change.isWorkInProgress() && !change.isPrivate());
     181             :     }
     182             : 
     183             :     // Add header that enables identifying comments on parsed email.
     184             :     // Grouping is currently done by timestamp.
     185          65 :     setHeader(MailHeader.COMMENT_DATE.fieldName(), timestamp);
     186             : 
     187          65 :     if (incomingEmailEnabled) {
     188           1 :       if (replyToAddress == null) {
     189             :         // Remove Reply-To and use outbound SMTP (default) instead.
     190           0 :         removeHeader(FieldName.REPLY_TO);
     191             :       } else {
     192           1 :         setHeader(FieldName.REPLY_TO, replyToAddress);
     193             :       }
     194             :     }
     195          65 :   }
     196             : 
     197             :   @Override
     198             :   public void formatChange() throws EmailException {
     199          65 :     appendText(textTemplate("Comment"));
     200          65 :     if (useHtml()) {
     201          65 :       appendHtml(soyHtmlTemplate("CommentHtml"));
     202             :     }
     203          65 :   }
     204             : 
     205             :   @Override
     206             :   public void formatFooter() throws EmailException {
     207          65 :     appendText(textTemplate("CommentFooter"));
     208          65 :     if (useHtml()) {
     209          65 :       appendHtml(soyHtmlTemplate("CommentFooterHtml"));
     210             :     }
     211          65 :   }
     212             : 
     213             :   /**
     214             :    * Returns a list of FileCommentGroup objects representing the inline comments grouped by the
     215             :    * file.
     216             :    */
     217             :   private List<CommentSender.FileCommentGroup> getGroupedInlineComments(Repository repo) {
     218          65 :     List<CommentSender.FileCommentGroup> groups = new ArrayList<>();
     219             : 
     220             :     // Loop over the comments and collect them into groups based on the file
     221             :     // location of the comment.
     222          65 :     FileCommentGroup currentGroup = null;
     223          65 :     for (Comment c : inlineComments) {
     224             :       // If it's a new group:
     225          23 :       if (currentGroup == null
     226           9 :           || !c.key.filename.equals(currentGroup.filename)
     227             :           || c.key.patchSetId != currentGroup.patchSetId) {
     228          23 :         currentGroup = new FileCommentGroup();
     229          23 :         currentGroup.filename = c.key.filename;
     230          23 :         currentGroup.patchSetId = c.key.patchSetId;
     231             :         // Get the modified files:
     232          23 :         Map<String, FileDiffOutput> modifiedFiles = listModifiedFiles(c.key.patchSetId);
     233             : 
     234          23 :         groups.add(currentGroup);
     235          23 :         if (modifiedFiles != null && !modifiedFiles.isEmpty()) {
     236             :           try {
     237          23 :             currentGroup.fileData = new PatchFile(repo, modifiedFiles, c.key.filename);
     238           7 :           } catch (IOException e) {
     239           7 :             logger.atWarning().withCause(e).log(
     240             :                 "Cannot load %s from %s in %s",
     241             :                 c.key.filename,
     242           7 :                 modifiedFiles.values().iterator().next().newCommitId().name(),
     243           7 :                 projectState.getName());
     244           7 :             currentGroup.fileData = null;
     245          23 :           }
     246             :         }
     247             :       }
     248             : 
     249          23 :       if (currentGroup.filename.equals(PATCHSET_LEVEL) || currentGroup.fileData != null) {
     250          23 :         currentGroup.comments.add(c);
     251             :       }
     252          23 :     }
     253             : 
     254          65 :     groups.sort(Comparator.comparing(g -> g.filename, FilenameComparator.INSTANCE));
     255          65 :     return groups;
     256             :   }
     257             : 
     258             :   /** Get the set of accounts whose comments have been replied to in this email. */
     259             :   private HashSet<Account.Id> getReplyAccounts() {
     260          65 :     HashSet<Account.Id> replyAccounts = new HashSet<>();
     261             :     // Track visited parent UUIDs to avoid cycles.
     262          65 :     HashSet<String> visitedUuids = new HashSet<>();
     263             : 
     264          65 :     for (Comment comment : inlineComments) {
     265          23 :       visitedUuids.add(comment.key.uuid);
     266             :       // Traverse the parent relation to the top of the comment thread.
     267          23 :       Comment current = comment;
     268          23 :       while (current.parentUuid != null && !visitedUuids.contains(current.parentUuid)) {
     269           5 :         Optional<HumanComment> optParent = getParent(current);
     270           5 :         if (!optParent.isPresent()) {
     271             :           // There is a parent UUID, but it cannot be loaded, break from the comment thread.
     272           3 :           break;
     273             :         }
     274             : 
     275           4 :         HumanComment parent = optParent.get();
     276           4 :         replyAccounts.add(parent.author.getId());
     277           4 :         visitedUuids.add(current.parentUuid);
     278           4 :         current = parent;
     279           4 :       }
     280          23 :     }
     281          65 :     return replyAccounts;
     282             :   }
     283             : 
     284             :   private String getCommentLinePrefix(Comment comment) {
     285          23 :     int lineNbr = comment.range == null ? comment.lineNbr : comment.range.startLine;
     286          23 :     StringBuilder sb = new StringBuilder();
     287          23 :     sb.append("PS").append(comment.key.patchSetId);
     288          23 :     if (lineNbr != 0) {
     289          21 :       sb.append(", Line ").append(lineNbr);
     290             :     }
     291          23 :     sb.append(": ");
     292          23 :     return sb.toString();
     293             :   }
     294             : 
     295             :   /**
     296             :    * Returns the lines of file content in fileData that are encompassed by range on the given side.
     297             :    */
     298             :   private List<String> getLinesByRange(Comment.Range range, PatchFile fileData, short side) {
     299           7 :     List<String> lines = new ArrayList<>();
     300             : 
     301           7 :     for (int n = range.startLine; n <= range.endLine; n++) {
     302           7 :       String s = getLine(fileData, side, n);
     303           7 :       if (n == range.startLine && n == range.endLine && range.startChar < range.endChar) {
     304           6 :         s = s.substring(Math.min(range.startChar, s.length()), Math.min(range.endChar, s.length()));
     305           2 :       } else if (n == range.startLine) {
     306           2 :         s = s.substring(Math.min(range.startChar, s.length()));
     307           2 :       } else if (n == range.endLine) {
     308           2 :         s = s.substring(0, Math.min(range.endChar, s.length()));
     309             :       }
     310           7 :       lines.add(s);
     311             :     }
     312           7 :     return lines;
     313             :   }
     314             : 
     315             :   /**
     316             :    * Get the parent comment of a given comment.
     317             :    *
     318             :    * @param child the comment with a potential parent comment.
     319             :    * @return an optional comment that will be present if the given comment has a parent, and is
     320             :    *     empty if it does not.
     321             :    */
     322             :   private Optional<HumanComment> getParent(Comment child) {
     323          23 :     if (child.parentUuid == null) {
     324          23 :       return Optional.empty();
     325             :     }
     326           5 :     Comment.Key key = new Comment.Key(child.parentUuid, child.key.filename, child.key.patchSetId);
     327             :     try {
     328           5 :       return commentsUtil.getPublishedHumanComment(changeData.notes(), key);
     329           0 :     } catch (StorageException e) {
     330           0 :       logger.atWarning().log("Could not find the parent of this comment: %s", child);
     331           0 :       return Optional.empty();
     332             :     }
     333             :   }
     334             : 
     335             :   /**
     336             :    * Retrieve the file lines referred to by a comment.
     337             :    *
     338             :    * @param comment The comment that refers to some file contents. The comment may be a line comment
     339             :    *     or a ranged comment.
     340             :    * @param fileData The file on which the comment appears.
     341             :    * @return file contents referred to by the comment. If the comment is a line comment, the result
     342             :    *     will be a list of one string. Otherwise it will be a list of one or more strings.
     343             :    */
     344             :   private List<String> getLinesOfComment(Comment comment, PatchFile fileData) {
     345          23 :     List<String> lines = new ArrayList<>();
     346          23 :     if (comment.lineNbr == 0) {
     347             :       // file level comment has no line
     348          12 :       return lines;
     349             :     }
     350          21 :     if (comment.range == null) {
     351          17 :       lines.add(getLine(fileData, comment.side, comment.lineNbr));
     352             :     } else {
     353           7 :       lines.addAll(getLinesByRange(comment.range, fileData, comment.side));
     354             :     }
     355          21 :     return lines;
     356             :   }
     357             : 
     358             :   /**
     359             :    * Returns a shortened version of the given comment's message. Will be shortened to 100 characters
     360             :    * or the first line, or following the last period within the first 100 characters, whichever is
     361             :    * shorter. If the message is shortened, an ellipsis is appended.
     362             :    */
     363             :   protected static String getShortenedCommentMessage(String message) {
     364           4 :     int threshold = 100;
     365           4 :     String fullMessage = message.trim();
     366           4 :     String msg = fullMessage;
     367             : 
     368           4 :     if (msg.length() > threshold) {
     369           1 :       msg = msg.substring(0, threshold);
     370             :     }
     371             : 
     372           4 :     int lf = msg.indexOf('\n');
     373           4 :     int period = msg.lastIndexOf('.');
     374             : 
     375           4 :     if (lf > 0) {
     376             :       // Truncate if a line feed appears within the threshold.
     377           1 :       msg = msg.substring(0, lf);
     378             : 
     379           4 :     } else if (period > 0) {
     380             :       // Otherwise truncate if there is a period within the threshold.
     381           1 :       msg = msg.substring(0, period + 1);
     382             :     }
     383             : 
     384             :     // Append an ellipsis if the message has been truncated.
     385           4 :     if (!msg.equals(fullMessage)) {
     386           1 :       msg += " […]";
     387             :     }
     388             : 
     389           4 :     return msg;
     390             :   }
     391             : 
     392             :   protected static String getShortenedCommentMessage(Comment comment) {
     393           3 :     return getShortenedCommentMessage(comment.message);
     394             :   }
     395             : 
     396             :   /**
     397             :    * Returns grouped inline comment data mapped to data structures that are suitable for passing
     398             :    * into Soy.
     399             :    */
     400             :   private List<Map<String, Object>> getCommentGroupsTemplateData(Repository repo) {
     401          65 :     List<Map<String, Object>> commentGroups = new ArrayList<>();
     402             : 
     403          65 :     for (CommentSender.FileCommentGroup group : getGroupedInlineComments(repo)) {
     404          23 :       Map<String, Object> groupData = new HashMap<>();
     405          23 :       groupData.put("title", group.getTitle());
     406          23 :       groupData.put("patchSetId", group.patchSetId);
     407             : 
     408          23 :       List<Map<String, Object>> commentsList = new ArrayList<>();
     409          23 :       for (Comment comment : group.comments) {
     410          23 :         Map<String, Object> commentData = new HashMap<>();
     411          23 :         if (group.fileData != null) {
     412          23 :           commentData.put("lines", getLinesOfComment(comment, group.fileData));
     413             :         }
     414          23 :         commentData.put("message", comment.message.trim());
     415          23 :         List<CommentFormatter.Block> blocks = CommentFormatter.parse(comment.message);
     416          23 :         commentData.put("messageBlocks", commentBlocksToSoyData(blocks));
     417             : 
     418             :         // Set the prefix.
     419          23 :         String prefix = getCommentLinePrefix(comment);
     420          23 :         commentData.put("linePrefix", prefix);
     421          23 :         commentData.put("linePrefixEmpty", Strings.padStart(": ", prefix.length(), ' '));
     422             : 
     423             :         // Set line numbers.
     424             :         int startLine;
     425          23 :         if (comment.range == null) {
     426          23 :           startLine = comment.lineNbr;
     427             :         } else {
     428           7 :           startLine = comment.range.startLine;
     429           7 :           commentData.put("endLine", comment.range.endLine);
     430             :         }
     431          23 :         commentData.put("startLine", startLine);
     432             : 
     433             :         // Set the comment link.
     434             : 
     435          23 :         if (comment.key.filename.equals(Patch.PATCHSET_LEVEL)) {
     436           5 :           if (comment instanceof RobotComment) {
     437           1 :             commentData.put("link", group.getFindingsTabLink());
     438             :           } else {
     439           4 :             commentData.put("link", group.getCommentsTabLink());
     440             :           }
     441             :         } else {
     442          22 :           commentData.put("link", group.getCommentLink(comment.key.uuid));
     443             :         }
     444             : 
     445             :         // Set robot comment data.
     446          23 :         if (comment instanceof RobotComment) {
     447           7 :           RobotComment robotComment = (RobotComment) comment;
     448           7 :           commentData.put("isRobotComment", true);
     449           7 :           commentData.put("robotId", robotComment.robotId);
     450           7 :           commentData.put("robotRunId", robotComment.robotRunId);
     451           7 :           commentData.put("robotUrl", robotComment.url);
     452           7 :         } else {
     453          22 :           commentData.put("isRobotComment", false);
     454             :         }
     455             : 
     456             :         // If the comment has a quote, don't bother loading the parent message.
     457          23 :         if (!hasQuote(blocks)) {
     458             :           // Set parent comment info.
     459          23 :           Optional<HumanComment> parent = getParent(comment);
     460          23 :           if (parent.isPresent()) {
     461           3 :             commentData.put("parentMessage", getShortenedCommentMessage(parent.get()));
     462             :           }
     463             :         }
     464             : 
     465          23 :         commentsList.add(commentData);
     466          23 :       }
     467          23 :       groupData.put("comments", commentsList);
     468             : 
     469          23 :       commentGroups.add(groupData);
     470          23 :     }
     471          65 :     return commentGroups;
     472             :   }
     473             : 
     474             :   private List<Map<String, Object>> commentBlocksToSoyData(List<CommentFormatter.Block> blocks) {
     475          65 :     return blocks.stream()
     476          65 :         .map(
     477             :             b -> {
     478          65 :               Map<String, Object> map = new HashMap<>();
     479          65 :               switch (b.type) {
     480             :                 case PARAGRAPH:
     481          65 :                   map.put("type", "paragraph");
     482          65 :                   map.put("text", b.text);
     483          65 :                   break;
     484             :                 case PRE_FORMATTED:
     485           0 :                   map.put("type", "pre");
     486           0 :                   map.put("text", b.text);
     487           0 :                   break;
     488             :                 case QUOTE:
     489           0 :                   map.put("type", "quote");
     490           0 :                   map.put("quotedBlocks", commentBlocksToSoyData(b.quotedBlocks));
     491           0 :                   break;
     492             :                 case LIST:
     493           0 :                   map.put("type", "list");
     494           0 :                   map.put("items", b.items);
     495             :                   break;
     496             :               }
     497          65 :               return map;
     498             :             })
     499          65 :         .collect(toList());
     500             :   }
     501             : 
     502             :   private boolean hasQuote(List<CommentFormatter.Block> blocks) {
     503          23 :     for (CommentFormatter.Block block : blocks) {
     504          23 :       if (block.type == CommentFormatter.BlockType.QUOTE) {
     505           0 :         return true;
     506             :       }
     507          23 :     }
     508          23 :     return false;
     509             :   }
     510             : 
     511             :   @Nullable
     512             :   private Repository getRepository() {
     513             :     try {
     514          65 :       return args.server.openRepository(projectState.getNameKey());
     515           0 :     } catch (IOException e) {
     516           0 :       return null;
     517             :     }
     518             :   }
     519             : 
     520             :   @Override
     521             :   protected void setupSoyContext() {
     522          65 :     super.setupSoyContext();
     523             :     boolean hasComments;
     524          65 :     try (Repository repo = getRepository()) {
     525          65 :       List<Map<String, Object>> files = getCommentGroupsTemplateData(repo);
     526          65 :       soyContext.put("commentFiles", files);
     527          65 :       hasComments = !files.isEmpty();
     528             :     }
     529             : 
     530          65 :     soyContext.put(
     531          65 :         "patchSetCommentBlocks", commentBlocksToSoyData(CommentFormatter.parse(patchSetComment)));
     532          65 :     soyContext.put("labels", getLabelVoteSoyData(labels));
     533          65 :     soyContext.put("commentCount", inlineComments.size());
     534          65 :     soyContext.put("commentTimestamp", getCommentTimestamp());
     535          65 :     soyContext.put(
     536          65 :         "coverLetterBlocks", commentBlocksToSoyData(CommentFormatter.parse(getCoverLetter())));
     537             : 
     538          65 :     if (isChangeNoLongerSubmittable()) {
     539           7 :       soyContext.put("unsatisfiedSubmitRequirements", formatUnsatisfiedSubmitRequirements());
     540           7 :       soyContext.put(
     541             :           "oldSubmitRequirements",
     542           7 :           formatSubmitRequirments(preUpdateSubmitRequirementResultsSupplier.get()));
     543           7 :       soyContext.put(
     544           7 :           "newSubmitRequirements", formatSubmitRequirments(postUpdateSubmitRequirementResults));
     545             :     }
     546             : 
     547          65 :     footers.add(MailHeader.COMMENT_DATE.withDelimiter() + getCommentTimestamp());
     548          65 :     footers.add(MailHeader.HAS_COMMENTS.withDelimiter() + (hasComments ? "Yes" : "No"));
     549          65 :     footers.add(MailHeader.HAS_LABELS.withDelimiter() + (labels.isEmpty() ? "No" : "Yes"));
     550             : 
     551          65 :     for (Account.Id account : getReplyAccounts()) {
     552           4 :       footers.add(MailHeader.COMMENT_IN_REPLY_TO.withDelimiter() + getNameEmailFor(account));
     553           4 :     }
     554          65 :   }
     555             : 
     556             :   /**
     557             :    * Checks whether the change is no longer submittable.
     558             :    *
     559             :    * @return {@code true} if the change has been submittable before the update and is no longer
     560             :    *     submittable after the update has been applied, otherwise {@code false}
     561             :    */
     562             :   private boolean isChangeNoLongerSubmittable() {
     563          65 :     boolean isSubmittablePreUpdate =
     564          65 :         preUpdateSubmitRequirementResultsSupplier.get().values().stream()
     565          65 :             .allMatch(SubmitRequirementResult::fulfilled);
     566          65 :     logger.atFine().log(
     567             :         "the submitability of change %s before the update is %s",
     568          65 :         change.getId(), isSubmittablePreUpdate);
     569          65 :     if (!isSubmittablePreUpdate) {
     570          65 :       return false;
     571             :     }
     572             : 
     573          18 :     boolean isSubmittablePostUpdate =
     574          18 :         postUpdateSubmitRequirementResults.values().stream()
     575          18 :             .allMatch(SubmitRequirementResult::fulfilled);
     576          18 :     logger.atFine().log(
     577             :         "the submitability of change %s after the update is %s",
     578          18 :         change.getId(), isSubmittablePostUpdate);
     579          18 :     return !isSubmittablePostUpdate;
     580             :   }
     581             : 
     582             :   private ImmutableList<String> formatUnsatisfiedSubmitRequirements() {
     583           7 :     return postUpdateSubmitRequirementResults.entrySet().stream()
     584           7 :         .filter(e -> SubmitRequirementResult.Status.UNSATISFIED.equals(e.getValue().status()))
     585           7 :         .map(Map.Entry::getKey)
     586           7 :         .map(SubmitRequirement::name)
     587           7 :         .sorted()
     588           7 :         .collect(toImmutableList());
     589             :   }
     590             : 
     591             :   private static ImmutableList<String> formatSubmitRequirments(
     592             :       Map<SubmitRequirement, SubmitRequirementResult> submitRequirementResults) {
     593           7 :     return submitRequirementResults.entrySet().stream()
     594           7 :         .map(
     595             :             e -> {
     596           7 :               if (e.getValue().errorMessage().isPresent()) {
     597           0 :                 return String.format(
     598             :                     "%s: %s (%s)",
     599           0 :                     e.getKey().name(),
     600           0 :                     e.getValue().status().name(),
     601           0 :                     e.getValue().errorMessage().get());
     602             :               }
     603           7 :               return String.format("%s: %s", e.getKey().name(), e.getValue().status().name());
     604             :             })
     605           7 :         .sorted()
     606           7 :         .collect(toImmutableList());
     607             :   }
     608             : 
     609             :   private String getLine(PatchFile fileInfo, short side, int lineNbr) {
     610             :     try {
     611          21 :       return fileInfo.getLine(side, lineNbr);
     612           0 :     } catch (IOException err) {
     613             :       // Default to the empty string if the file cannot be safely read.
     614           0 :       logger.atWarning().withCause(err).log("Failed to read file on side %d", side);
     615           0 :       return "";
     616           1 :     } catch (IndexOutOfBoundsException err) {
     617             :       // Default to the empty string if the given line number does not appear
     618             :       // in the file.
     619           1 :       logger.atFine().withCause(err).log(
     620             :           "Failed to get line number %d of file on side %d", lineNbr, side);
     621           1 :       return "";
     622           1 :     } catch (NoSuchEntityException err) {
     623             :       // Default to the empty string if the side cannot be found.
     624           1 :       logger.atWarning().withCause(err).log("Side %d of file didn't exist", side);
     625           1 :       return "";
     626             :     }
     627             :   }
     628             : 
     629             :   private ImmutableList<Map<String, Object>> getLabelVoteSoyData(ImmutableList<LabelVote> votes) {
     630          65 :     ImmutableList.Builder<Map<String, Object>> result = ImmutableList.builder();
     631          65 :     for (LabelVote vote : votes) {
     632          58 :       Map<String, Object> data = new HashMap<>();
     633          58 :       data.put("label", vote.label());
     634             : 
     635             :       // Soy needs the short to be cast as an int for it to get converted to the
     636             :       // correct tamplate type.
     637          58 :       data.put("value", (int) vote.value());
     638          58 :       result.add(data);
     639          58 :     }
     640          65 :     return result.build();
     641             :   }
     642             : 
     643             :   private String getCommentTimestamp() {
     644             :     // Grouping is currently done by timestamp.
     645          65 :     return MailProcessingUtil.rfcDateformatter.format(
     646          65 :         ZonedDateTime.ofInstant(timestamp, ZoneId.of("UTC")));
     647             :   }
     648             : }

Generated by: LCOV version 1.16+git.20220603.dfeb750