LCOV - code coverage report
Current view: top level - server/mail/send - ChangeEmail.java (source / functions) Hit Total Coverage
Test: _coverage_report.dat Lines: 279 322 86.6 %
Date: 2022-11-19 15:00:39 Functions: 38 40 95.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.server.util.AttentionSetUtil.additionsOnly;
      19             : 
      20             : import com.google.common.base.Splitter;
      21             : import com.google.common.collect.ImmutableList;
      22             : import com.google.common.collect.ImmutableMap;
      23             : import com.google.common.collect.ListMultimap;
      24             : import com.google.common.flogger.FluentLogger;
      25             : import com.google.gerrit.common.Nullable;
      26             : import com.google.gerrit.entities.Account;
      27             : import com.google.gerrit.entities.AttentionSetUpdate;
      28             : import com.google.gerrit.entities.Change;
      29             : import com.google.gerrit.entities.ChangeSizeBucket;
      30             : import com.google.gerrit.entities.NotifyConfig.NotifyType;
      31             : import com.google.gerrit.entities.Patch;
      32             : import com.google.gerrit.entities.PatchSet;
      33             : import com.google.gerrit.entities.PatchSetInfo;
      34             : import com.google.gerrit.entities.Project;
      35             : import com.google.gerrit.exceptions.EmailException;
      36             : import com.google.gerrit.exceptions.StorageException;
      37             : import com.google.gerrit.extensions.api.changes.NotifyHandling;
      38             : import com.google.gerrit.extensions.api.changes.RecipientType;
      39             : import com.google.gerrit.extensions.client.GeneralPreferencesInfo.EmailStrategy;
      40             : import com.google.gerrit.extensions.restapi.AuthException;
      41             : import com.google.gerrit.mail.MailHeader;
      42             : import com.google.gerrit.server.StarredChangesUtil;
      43             : import com.google.gerrit.server.account.AccountState;
      44             : import com.google.gerrit.server.mail.send.ProjectWatch.Watchers;
      45             : import com.google.gerrit.server.notedb.ReviewerStateInternal;
      46             : import com.google.gerrit.server.patch.DiffNotAvailableException;
      47             : import com.google.gerrit.server.patch.DiffOptions;
      48             : import com.google.gerrit.server.patch.FilePathAdapter;
      49             : import com.google.gerrit.server.patch.PatchSetInfoNotAvailableException;
      50             : import com.google.gerrit.server.patch.filediff.FileDiffOutput;
      51             : import com.google.gerrit.server.permissions.ChangePermission;
      52             : import com.google.gerrit.server.permissions.GlobalPermission;
      53             : import com.google.gerrit.server.permissions.PermissionBackendException;
      54             : import com.google.gerrit.server.project.ProjectState;
      55             : import com.google.gerrit.server.query.change.ChangeData;
      56             : import java.io.IOException;
      57             : import java.net.URI;
      58             : import java.net.URISyntaxException;
      59             : import java.text.MessageFormat;
      60             : import java.time.Instant;
      61             : import java.util.Collection;
      62             : import java.util.HashMap;
      63             : import java.util.HashSet;
      64             : import java.util.Map;
      65             : import java.util.Optional;
      66             : import java.util.Set;
      67             : import java.util.TreeMap;
      68             : import java.util.TreeSet;
      69             : import java.util.stream.Collectors;
      70             : import org.apache.http.client.utils.URIBuilder;
      71             : import org.apache.james.mime4j.dom.field.FieldName;
      72             : import org.eclipse.jgit.diff.DiffFormatter;
      73             : import org.eclipse.jgit.internal.JGitText;
      74             : import org.eclipse.jgit.lib.ObjectId;
      75             : import org.eclipse.jgit.lib.Repository;
      76             : import org.eclipse.jgit.util.RawParseUtils;
      77             : import org.eclipse.jgit.util.TemporaryBuffer;
      78             : 
      79             : /** Sends an email to one or more interested parties. */
      80             : public abstract class ChangeEmail extends NotificationEmail {
      81             : 
      82         152 :   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
      83             : 
      84             :   protected static ChangeData newChangeData(
      85             :       EmailArguments ea, Project.NameKey project, Change.Id id) {
      86         103 :     return ea.changeDataFactory.create(project, id);
      87             :   }
      88             : 
      89             :   protected static ChangeData newChangeData(
      90             :       EmailArguments ea, Project.NameKey project, Change.Id id, ObjectId metaId) {
      91          78 :     return ea.changeDataFactory.create(ea.changeNotesFactory.createChecked(project, id, metaId));
      92             :   }
      93             : 
      94             :   private final Set<Account.Id> currentAttentionSet;
      95             :   protected final Change change;
      96             :   protected final ChangeData changeData;
      97             :   protected ListMultimap<Account.Id, String> stars;
      98             :   protected PatchSet patchSet;
      99             :   protected PatchSetInfo patchSetInfo;
     100             :   protected String changeMessage;
     101             :   protected Instant timestamp;
     102             : 
     103             :   protected ProjectState projectState;
     104             :   protected Set<Account.Id> authors;
     105             :   protected boolean emailOnlyAuthors;
     106             :   protected boolean emailOnlyAttentionSetIfEnabled;
     107             : 
     108             :   protected ChangeEmail(EmailArguments args, String messageClass, ChangeData changeData) {
     109         103 :     super(args, messageClass, changeData.change().getDest());
     110         103 :     this.changeData = changeData;
     111         103 :     change = changeData.change();
     112         103 :     emailOnlyAuthors = false;
     113         103 :     emailOnlyAttentionSetIfEnabled = true;
     114         103 :     currentAttentionSet = getAttentionSet();
     115         103 :   }
     116             : 
     117             :   @Override
     118             :   public void setFrom(Account.Id id) {
     119         103 :     super.setFrom(id);
     120             : 
     121             :     // Is the from user in an email squelching group?
     122             :     try {
     123         103 :       args.permissionBackend.absentUser(id).check(GlobalPermission.EMAIL_REVIEWERS);
     124           0 :     } catch (AuthException | PermissionBackendException e) {
     125           0 :       emailOnlyAuthors = true;
     126         103 :     }
     127         103 :   }
     128             : 
     129             :   public void setPatchSet(PatchSet ps) {
     130           0 :     patchSet = ps;
     131           0 :   }
     132             : 
     133             :   public void setPatchSet(PatchSet ps, PatchSetInfo psi) {
     134         103 :     patchSet = ps;
     135         103 :     patchSetInfo = psi;
     136         103 :   }
     137             : 
     138             :   public void setChangeMessage(String cm, Instant t) {
     139          78 :     changeMessage = cm;
     140          78 :     timestamp = t;
     141          78 :   }
     142             : 
     143             :   /** Format the message body by calling {@link #appendText(String)}. */
     144             :   @Override
     145             :   protected void format() throws EmailException {
     146         103 :     if (useHtml()) {
     147         103 :       appendHtml(soyHtmlTemplate("ChangeHeaderHtml"));
     148             :     }
     149         103 :     appendText(textTemplate("ChangeHeader"));
     150         103 :     formatChange();
     151         103 :     appendText(textTemplate("ChangeFooter"));
     152         103 :     if (useHtml()) {
     153         103 :       appendHtml(soyHtmlTemplate("ChangeFooterHtml"));
     154             :     }
     155         103 :     formatFooter();
     156         103 :   }
     157             : 
     158             :   /** Format the message body by calling {@link #appendText(String)}. */
     159             :   protected abstract void formatChange() throws EmailException;
     160             : 
     161             :   /**
     162             :    * Format the message footer by calling {@link #appendText(String)}.
     163             :    *
     164             :    * @throws EmailException if an error occurred.
     165             :    */
     166         103 :   protected void formatFooter() throws EmailException {}
     167             : 
     168             :   /** Setup the message headers and envelope (TO, CC, BCC). */
     169             :   @Override
     170             :   protected void init() throws EmailException {
     171         103 :     if (args.projectCache != null) {
     172         103 :       projectState = args.projectCache.get(change.getProject()).orElse(null);
     173             :     } else {
     174           0 :       projectState = null;
     175             :     }
     176             : 
     177         103 :     if (patchSet == null) {
     178             :       try {
     179          64 :         patchSet = changeData.currentPatchSet();
     180           0 :       } catch (StorageException err) {
     181           0 :         patchSet = null;
     182          64 :       }
     183             :     }
     184             : 
     185         103 :     if (patchSet != null) {
     186         103 :       setHeader(MailHeader.PATCH_SET.fieldName(), patchSet.number() + "");
     187         103 :       if (patchSetInfo == null) {
     188             :         try {
     189          64 :           patchSetInfo = args.patchSetInfoFactory.get(changeData.notes(), patchSet.id());
     190           0 :         } catch (PatchSetInfoNotAvailableException | StorageException err) {
     191           0 :           patchSetInfo = null;
     192          64 :         }
     193             :       }
     194             :     }
     195         103 :     authors = getAuthors();
     196             : 
     197             :     try {
     198         103 :       stars = changeData.stars();
     199           0 :     } catch (StorageException e) {
     200           0 :       throw new EmailException("Failed to load stars for change " + change.getChangeId(), e);
     201         103 :     }
     202             : 
     203         103 :     super.init();
     204         103 :     if (timestamp != null) {
     205          78 :       setHeader(FieldName.DATE, timestamp);
     206             :     }
     207         103 :     setChangeSubjectHeader();
     208         103 :     setHeader(MailHeader.CHANGE_ID.fieldName(), "" + change.getKey().get());
     209         103 :     setHeader(MailHeader.CHANGE_NUMBER.fieldName(), "" + change.getChangeId());
     210         103 :     setHeader(MailHeader.PROJECT.fieldName(), "" + change.getProject());
     211         103 :     setChangeUrlHeader();
     212         103 :     setCommitIdHeader();
     213             : 
     214         103 :     if (notify.handling().compareTo(NotifyHandling.OWNER_REVIEWERS) >= 0) {
     215             :       try {
     216         103 :         addByEmail(
     217         103 :             RecipientType.CC, changeData.reviewersByEmail().byState(ReviewerStateInternal.CC));
     218         103 :         addByEmail(
     219             :             RecipientType.CC,
     220         103 :             changeData.reviewersByEmail().byState(ReviewerStateInternal.REVIEWER));
     221           0 :       } catch (StorageException e) {
     222           0 :         throw new EmailException("Failed to add unregistered CCs " + change.getChangeId(), e);
     223         103 :       }
     224             :     }
     225         103 :   }
     226             : 
     227             :   private void setChangeUrlHeader() {
     228         103 :     final String u = getChangeUrl();
     229         103 :     if (u != null) {
     230         103 :       setHeader(MailHeader.CHANGE_URL.fieldName(), "<" + u + ">");
     231             :     }
     232         103 :   }
     233             : 
     234             :   private void setCommitIdHeader() {
     235         103 :     if (patchSet != null) {
     236         103 :       setHeader(MailHeader.COMMIT.fieldName(), patchSet.commitId().name());
     237             :     }
     238         103 :   }
     239             : 
     240             :   private void setChangeSubjectHeader() {
     241         103 :     setHeader(FieldName.SUBJECT, textTemplate("ChangeSubject"));
     242         103 :   }
     243             : 
     244             :   private int getInsertionsCount() {
     245         103 :     return listModifiedFiles().values().stream()
     246         103 :         .map(FileDiffOutput::insertions)
     247         103 :         .reduce(0, Integer::sum);
     248             :   }
     249             : 
     250             :   private int getDeletionsCount() {
     251         103 :     return listModifiedFiles().values().stream()
     252         103 :         .map(FileDiffOutput::deletions)
     253         103 :         .reduce(0, Integer::sum);
     254             :   }
     255             : 
     256             :   /**
     257             :    * Get a link to the change; null if the server doesn't know its own address or if the address is
     258             :    * malformed. The link will contain a usp parameter set to "email" to inform the frontend on
     259             :    * clickthroughs where the link came from.
     260             :    */
     261             :   @Nullable
     262             :   public String getChangeUrl() {
     263         103 :     Optional<String> changeUrl =
     264         103 :         args.urlFormatter.get().getChangeViewUrl(change.getProject(), change.getId());
     265         103 :     if (!changeUrl.isPresent()) return null;
     266             :     try {
     267         103 :       URI uri = new URIBuilder(changeUrl.get()).addParameter("usp", "email").build();
     268         103 :       return uri.toString();
     269           0 :     } catch (URISyntaxException e) {
     270           0 :       return null;
     271             :     }
     272             :   }
     273             : 
     274             :   public String getChangeMessageThreadId() {
     275         103 :     return "<gerrit."
     276         103 :         + change.getCreatedOn().toEpochMilli()
     277             :         + "."
     278         103 :         + change.getKey().get()
     279             :         + "@"
     280         103 :         + getGerritHost()
     281             :         + ">";
     282             :   }
     283             : 
     284             :   /** Get the text of the "cover letter". */
     285             :   public String getCoverLetter() {
     286         103 :     if (changeMessage != null) {
     287          78 :       return changeMessage.trim();
     288             :     }
     289         103 :     return "";
     290             :   }
     291             : 
     292             :   /** Create the change message and the affected file list. */
     293             :   public String getChangeDetail() {
     294             :     try {
     295         103 :       StringBuilder detail = new StringBuilder();
     296             : 
     297         103 :       if (patchSetInfo != null) {
     298         103 :         detail.append(patchSetInfo.getMessage().trim()).append("\n");
     299             :       } else {
     300           0 :         detail.append(change.getSubject().trim()).append("\n");
     301             :       }
     302             : 
     303         103 :       if (patchSet != null) {
     304         103 :         detail.append("---\n");
     305             :         // Sort files by name.
     306         103 :         TreeMap<String, FileDiffOutput> modifiedFiles = new TreeMap<>(listModifiedFiles());
     307         103 :         for (FileDiffOutput fileDiff : modifiedFiles.values()) {
     308         103 :           if (fileDiff.newPath().isPresent() && Patch.isMagic(fileDiff.newPath().get())) {
     309         103 :             continue;
     310             :           }
     311          90 :           detail
     312          90 :               .append(fileDiff.changeType().getCode())
     313          90 :               .append(" ")
     314          90 :               .append(
     315          90 :                   FilePathAdapter.getNewPath(
     316          90 :                       fileDiff.oldPath(), fileDiff.newPath(), fileDiff.changeType()))
     317          90 :               .append("\n");
     318          90 :         }
     319         103 :         detail.append(
     320         103 :             MessageFormat.format(
     321             :                 "" //
     322             :                     + "{0,choice,0#0 files|1#1 file|1<{0} files} changed, " //
     323             :                     + "{1,choice,0#0 insertions|1#1 insertion|1<{1} insertions}(+), " //
     324             :                     + "{2,choice,0#0 deletions|1#1 deletion|1<{2} deletions}(-)" //
     325             :                     + "\n",
     326         103 :                 modifiedFiles.size() - 1, //
     327         103 :                 getInsertionsCount(), //
     328         103 :                 getDeletionsCount()));
     329         103 :         detail.append("\n");
     330             :       }
     331         103 :       return detail.toString();
     332           0 :     } catch (Exception err) {
     333           0 :       logger.atWarning().withCause(err).log("Cannot format change detail");
     334           0 :       return "";
     335             :     }
     336             :   }
     337             : 
     338             :   /** Get the patch list corresponding to patch set patchSetId of this change. */
     339             :   protected Map<String, FileDiffOutput> listModifiedFiles(int patchSetId) {
     340             :     try {
     341             :       PatchSet ps;
     342          23 :       if (patchSetId == patchSet.number()) {
     343          20 :         ps = patchSet;
     344             :       } else {
     345           5 :         ps = args.patchSetUtil.get(changeData.notes(), PatchSet.id(change.getId(), patchSetId));
     346             :       }
     347          23 :       return args.diffOperations.listModifiedFilesAgainstParent(
     348          23 :           change.getProject(), ps.commitId(), /* parentNum= */ 0, DiffOptions.DEFAULTS);
     349           0 :     } catch (StorageException | DiffNotAvailableException e) {
     350           0 :       logger.atSevere().withCause(e).log("Failed to get modified files");
     351           0 :       return new HashMap<>();
     352             :     }
     353             :   }
     354             : 
     355             :   /** Get the patch list corresponding to this patch set. */
     356             :   protected Map<String, FileDiffOutput> listModifiedFiles() {
     357         103 :     if (patchSet != null) {
     358             :       try {
     359         103 :         return args.diffOperations.listModifiedFilesAgainstParent(
     360         103 :             change.getProject(), patchSet.commitId(), /* parentNum= */ 0, DiffOptions.DEFAULTS);
     361           0 :       } catch (DiffNotAvailableException e) {
     362           0 :         logger.atSevere().withCause(e).log("Failed to get modified files");
     363           0 :       }
     364             :     } else {
     365           0 :       logger.atSevere().log("no patchSet specified");
     366             :     }
     367           0 :     return new HashMap<>();
     368             :   }
     369             : 
     370             :   /** Get the project entity the change is in; null if its been deleted. */
     371             :   protected ProjectState getProjectState() {
     372           0 :     return projectState;
     373             :   }
     374             : 
     375             :   /** TO or CC all vested parties (change owner, patch set uploader, author). */
     376             :   protected void rcptToAuthors(RecipientType rt) {
     377         103 :     for (Account.Id id : authors) {
     378         103 :       add(rt, id);
     379         103 :     }
     380         103 :   }
     381             : 
     382             :   /** BCC any user who has starred this change. */
     383             :   protected void bccStarredBy() {
     384          84 :     if (!NotifyHandling.ALL.equals(notify.handling())) {
     385          20 :       return;
     386             :     }
     387             : 
     388          84 :     for (Map.Entry<Account.Id, Collection<String>> e : stars.asMap().entrySet()) {
     389           1 :       if (e.getValue().contains(StarredChangesUtil.DEFAULT_LABEL)) {
     390           1 :         super.add(RecipientType.BCC, e.getKey());
     391             :       }
     392           1 :     }
     393          84 :   }
     394             : 
     395             :   @Override
     396             :   protected final Watchers getWatchers(NotifyType type, boolean includeWatchersFromNotifyConfig) {
     397         103 :     if (!NotifyHandling.ALL.equals(notify.handling())) {
     398          16 :       return new Watchers();
     399             :     }
     400             : 
     401         103 :     ProjectWatch watch = new ProjectWatch(args, branch.project(), projectState, changeData);
     402         103 :     return watch.getWatchers(type, includeWatchersFromNotifyConfig);
     403             :   }
     404             : 
     405             :   /** Any user who has published comments on this change. */
     406             :   protected void ccAllApprovals() {
     407          74 :     if (!NotifyHandling.ALL.equals(notify.handling())
     408          18 :         && !NotifyHandling.OWNER_REVIEWERS.equals(notify.handling())) {
     409          16 :       return;
     410             :     }
     411             : 
     412             :     try {
     413          74 :       for (Account.Id id : changeData.reviewers().all()) {
     414          70 :         add(RecipientType.CC, id);
     415          70 :       }
     416           0 :     } catch (StorageException err) {
     417           0 :       logger.atWarning().withCause(err).log("Cannot CC users that reviewed updated change");
     418          74 :     }
     419          74 :   }
     420             : 
     421             :   /** Users who were added as reviewers to this change. */
     422             :   protected void ccExistingReviewers() {
     423          38 :     if (!NotifyHandling.ALL.equals(notify.handling())
     424          14 :         && !NotifyHandling.OWNER_REVIEWERS.equals(notify.handling())) {
     425          14 :       return;
     426             :     }
     427             : 
     428             :     try {
     429          32 :       for (Account.Id id : changeData.reviewers().byState(ReviewerStateInternal.REVIEWER)) {
     430          31 :         add(RecipientType.CC, id);
     431          31 :       }
     432           0 :     } catch (StorageException err) {
     433           0 :       logger.atWarning().withCause(err).log("Cannot CC users that commented on updated change");
     434          32 :     }
     435          32 :   }
     436             : 
     437             :   @Override
     438             :   protected void add(RecipientType rt, Account.Id to) {
     439         103 :     addRecipient(rt, to, /* isWatcher= */ false);
     440         103 :   }
     441             : 
     442             :   /** This bypasses the EmailStrategy.ATTENTION_SET_ONLY strategy when adding the recipient. */
     443             :   @Override
     444             :   protected void addWatcher(RecipientType rt, Account.Id to) {
     445           8 :     addRecipient(rt, to, /* isWatcher= */ true);
     446           8 :   }
     447             : 
     448             :   private void addRecipient(RecipientType rt, Account.Id to, boolean isWatcher) {
     449         103 :     if (!isWatcher) {
     450         103 :       Optional<AccountState> accountState = args.accountCache.get(to);
     451         103 :       if (emailOnlyAttentionSetIfEnabled
     452         103 :           && accountState.isPresent()
     453         103 :           && accountState.get().generalPreferences().getEmailStrategy()
     454             :               == EmailStrategy.ATTENTION_SET_ONLY
     455           1 :           && !currentAttentionSet.contains(to)) {
     456           1 :         return;
     457             :       }
     458             :     }
     459         103 :     if (emailOnlyAuthors && !authors.contains(to)) {
     460           0 :       return;
     461             :     }
     462         103 :     super.add(rt, to);
     463         103 :   }
     464             : 
     465             :   @Override
     466             :   protected boolean isVisibleTo(Account.Id to) throws PermissionBackendException {
     467         103 :     if (!projectState.statePermitsRead()) {
     468           0 :       return false;
     469             :     }
     470         103 :     return args.permissionBackend.absentUser(to).change(changeData).test(ChangePermission.READ);
     471             :   }
     472             : 
     473             :   /** Find all users who are authors of any part of this change. */
     474             :   protected Set<Account.Id> getAuthors() {
     475         103 :     Set<Account.Id> authors = new HashSet<>();
     476             : 
     477         103 :     switch (notify.handling()) {
     478             :       case NONE:
     479           8 :         break;
     480             :       case ALL:
     481             :       default:
     482         103 :         if (patchSet != null) {
     483         103 :           authors.add(patchSet.uploader());
     484             :         }
     485         103 :         if (patchSetInfo != null) {
     486         103 :           if (patchSetInfo.getAuthor().getAccount() != null) {
     487         100 :             authors.add(patchSetInfo.getAuthor().getAccount());
     488             :           }
     489         103 :           if (patchSetInfo.getCommitter().getAccount() != null) {
     490         101 :             authors.add(patchSetInfo.getCommitter().getAccount());
     491             :           }
     492             :         }
     493             :         // $FALL-THROUGH$
     494             :       case OWNER_REVIEWERS:
     495             :       case OWNER:
     496         103 :         authors.add(change.getOwner());
     497             :         break;
     498             :     }
     499             : 
     500         103 :     return authors;
     501             :   }
     502             : 
     503             :   @Override
     504             :   protected void setupSoyContext() {
     505         103 :     super.setupSoyContext();
     506             : 
     507         103 :     soyContext.put("changeId", change.getKey().get());
     508         103 :     soyContext.put("coverLetter", getCoverLetter());
     509         103 :     soyContext.put("fromName", getNameFor(fromId));
     510         103 :     soyContext.put("fromEmail", getNameEmailFor(fromId));
     511         103 :     soyContext.put("diffLines", getDiffTemplateData(getUnifiedDiff()));
     512             : 
     513         103 :     soyContextEmailData.put("unifiedDiff", getUnifiedDiff());
     514         103 :     soyContextEmailData.put("changeDetail", getChangeDetail());
     515         103 :     soyContextEmailData.put("changeUrl", getChangeUrl());
     516         103 :     soyContextEmailData.put("includeDiff", getIncludeDiff());
     517             : 
     518         103 :     Map<String, String> changeData = new HashMap<>();
     519             : 
     520         103 :     String subject = change.getSubject();
     521         103 :     String originalSubject = change.getOriginalSubject();
     522         103 :     changeData.put("subject", subject);
     523         103 :     changeData.put("originalSubject", originalSubject);
     524         103 :     changeData.put("shortSubject", shortenSubject(subject));
     525         103 :     changeData.put("shortOriginalSubject", shortenSubject(originalSubject));
     526             : 
     527         103 :     changeData.put("ownerName", getNameFor(change.getOwner()));
     528         103 :     changeData.put("ownerEmail", getNameEmailFor(change.getOwner()));
     529         103 :     changeData.put("changeNumber", Integer.toString(change.getChangeId()));
     530         103 :     changeData.put(
     531             :         "sizeBucket",
     532         103 :         ChangeSizeBucket.getChangeSizeBucket(getInsertionsCount() + getDeletionsCount()));
     533         103 :     soyContext.put("change", changeData);
     534             : 
     535         103 :     Map<String, Object> patchSetData = new HashMap<>();
     536         103 :     patchSetData.put("patchSetId", patchSet.number());
     537         103 :     patchSetData.put("refName", patchSet.refName());
     538         103 :     soyContext.put("patchSet", patchSetData);
     539             : 
     540         103 :     Map<String, Object> patchSetInfoData = new HashMap<>();
     541         103 :     patchSetInfoData.put("authorName", patchSetInfo.getAuthor().getName());
     542         103 :     patchSetInfoData.put("authorEmail", patchSetInfo.getAuthor().getEmail());
     543         103 :     soyContext.put("patchSetInfo", patchSetInfoData);
     544             : 
     545         103 :     footers.add(MailHeader.CHANGE_ID.withDelimiter() + change.getKey().get());
     546         103 :     footers.add(MailHeader.CHANGE_NUMBER.withDelimiter() + change.getChangeId());
     547         103 :     footers.add(MailHeader.PATCH_SET.withDelimiter() + patchSet.number());
     548         103 :     footers.add(MailHeader.OWNER.withDelimiter() + getNameEmailFor(change.getOwner()));
     549         103 :     if (change.getAssignee() != null) {
     550           6 :       footers.add(MailHeader.ASSIGNEE.withDelimiter() + getNameEmailFor(change.getAssignee()));
     551             :     }
     552         103 :     for (String reviewer : getEmailsByState(ReviewerStateInternal.REVIEWER)) {
     553          73 :       footers.add(MailHeader.REVIEWER.withDelimiter() + reviewer);
     554          73 :     }
     555         103 :     for (String reviewer : getEmailsByState(ReviewerStateInternal.CC)) {
     556          27 :       footers.add(MailHeader.CC.withDelimiter() + reviewer);
     557          27 :     }
     558         103 :     for (Account.Id attentionUser : currentAttentionSet) {
     559          51 :       footers.add(MailHeader.ATTENTION.withDelimiter() + getNameEmailFor(attentionUser));
     560          51 :     }
     561             :     // Since this would be user visible, only show it if attention set is enabled
     562         103 :     if (args.settings.isAttentionSetEnabled && !currentAttentionSet.isEmpty()) {
     563             :       // We need names rather than account ids / emails to make it user readable.
     564          51 :       soyContext.put(
     565             :           "attentionSet",
     566          51 :           currentAttentionSet.stream().map(this::getNameFor).sorted().collect(toImmutableList()));
     567             :     }
     568         103 :   }
     569             : 
     570             :   /**
     571             :    * A shortened subject is the subject limited to 72 characters, with an ellipsis if it exceeds
     572             :    * that limit.
     573             :    */
     574             :   private static String shortenSubject(String subject) {
     575         103 :     if (subject.length() < 73) {
     576         103 :       return subject;
     577             :     }
     578           5 :     return subject.substring(0, 69) + "...";
     579             :   }
     580             : 
     581             :   private Set<String> getEmailsByState(ReviewerStateInternal state) {
     582         103 :     Set<String> reviewers = new TreeSet<>();
     583             :     try {
     584         103 :       for (Account.Id who : changeData.reviewers().byState(state)) {
     585          74 :         reviewers.add(getNameEmailFor(who));
     586          74 :       }
     587           0 :     } catch (StorageException e) {
     588           0 :       logger.atWarning().withCause(e).log("Cannot get change reviewers");
     589         103 :     }
     590         103 :     return reviewers;
     591             :   }
     592             : 
     593             :   private Set<Account.Id> getAttentionSet() {
     594         103 :     Set<Account.Id> attentionSet = new TreeSet<>();
     595             :     try {
     596         103 :       attentionSet =
     597         103 :           additionsOnly(changeData.attentionSet()).stream()
     598         103 :               .map(AttentionSetUpdate::account)
     599         103 :               .collect(Collectors.toSet());
     600           0 :     } catch (StorageException e) {
     601           0 :       logger.atWarning().withCause(e).log("Cannot get change attention set");
     602         103 :     }
     603         103 :     return attentionSet;
     604             :   }
     605             : 
     606             :   public boolean getIncludeDiff() {
     607         103 :     return args.settings.includeDiff;
     608             :   }
     609             : 
     610             :   private static final int HEAP_EST_SIZE = 32 * 1024;
     611             : 
     612             :   /** Show patch set as unified difference. */
     613             :   public String getUnifiedDiff() {
     614             :     Map<String, FileDiffOutput> modifiedFiles;
     615         103 :     modifiedFiles = listModifiedFiles();
     616         103 :     if (modifiedFiles.isEmpty()) {
     617             :       // Octopus merges are not well supported for diff output by Gerrit.
     618             :       // Currently these always have a null oldId in the PatchList.
     619           0 :       return "[Empty change (potentially Octopus merge); cannot be formatted as a diff.]\n";
     620             :     }
     621             : 
     622         103 :     int maxSize = args.settings.maximumDiffSize;
     623         103 :     TemporaryBuffer.Heap buf = new TemporaryBuffer.Heap(Math.min(HEAP_EST_SIZE, maxSize), maxSize);
     624         103 :     try (DiffFormatter fmt = new DiffFormatter(buf)) {
     625         103 :       try (Repository git = args.server.openRepository(change.getProject())) {
     626             :         try {
     627         103 :           ObjectId oldId = modifiedFiles.values().iterator().next().oldCommitId();
     628         103 :           ObjectId newId = modifiedFiles.values().iterator().next().newCommitId();
     629         103 :           if (oldId.equals(ObjectId.zeroId())) {
     630             :             // DiffOperations returns ObjectId.zeroId if newCommit is a root commit, i.e. has no
     631             :             // parents.
     632          30 :             oldId = null;
     633             :           }
     634         103 :           fmt.setRepository(git);
     635         103 :           fmt.setDetectRenames(true);
     636         103 :           fmt.format(oldId, newId);
     637         103 :           return RawParseUtils.decode(buf.toByteArray());
     638           1 :         } catch (IOException e) {
     639           1 :           if (JGitText.get().inMemoryBufferLimitExceeded.equals(e.getMessage())) {
     640           1 :             return "";
     641             :           }
     642           0 :           logger.atSevere().withCause(e).log("Cannot format patch");
     643           0 :           return "";
     644             :         }
     645         103 :       } catch (IOException e) {
     646           0 :         logger.atSevere().withCause(e).log("Cannot open repository to format patch");
     647           0 :         return "";
     648             :       }
     649         103 :     }
     650             :   }
     651             : 
     652             :   /**
     653             :    * Generate a list of maps representing each line of the unified diff. The line maps will have a
     654             :    * 'type' key which maps to one of 'common', 'add' or 'remove' and a 'text' key which maps to the
     655             :    * line's content.
     656             :    *
     657             :    * @param sourceDiff the unified diff that we're converting to the map.
     658             :    * @return map of 'type' to a line's content.
     659             :    */
     660             :   protected static ImmutableList<ImmutableMap<String, String>> getDiffTemplateData(
     661             :       String sourceDiff) {
     662         103 :     ImmutableList.Builder<ImmutableMap<String, String>> result = ImmutableList.builder();
     663         103 :     Splitter lineSplitter = Splitter.on(System.getProperty("line.separator"));
     664         103 :     for (String diffLine : lineSplitter.split(sourceDiff)) {
     665         103 :       ImmutableMap.Builder<String, String> lineData = ImmutableMap.builder();
     666         103 :       lineData.put("text", diffLine);
     667             : 
     668             :       // Skip empty lines and lines that look like diff headers.
     669         103 :       if (diffLine.isEmpty() || diffLine.startsWith("---") || diffLine.startsWith("+++")) {
     670         103 :         lineData.put("type", "common");
     671             :       } else {
     672          90 :         switch (diffLine.charAt(0)) {
     673             :           case '+':
     674          90 :             lineData.put("type", "add");
     675          90 :             break;
     676             :           case '-':
     677          50 :             lineData.put("type", "remove");
     678          50 :             break;
     679             :           default:
     680          90 :             lineData.put("type", "common");
     681             :             break;
     682             :         }
     683             :       }
     684         103 :       result.add(lineData.build());
     685         103 :     }
     686         103 :     return result.build();
     687             :   }
     688             : }

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