Files
gerrit/java/com/google/gerrit/server/mail/send/ChangeEmail.java
David Pursehouse e8c846468a Stop using deprecated SoyListData and SoyMapData
Per the deprecation notices, users of these classes should use normal
List and Map instead. The Soy rendering APIs can automatically handle
conversion of native Java types.

Change-Id: I101e455fea721a7cb083dd2b26c59e5e7527acf8
2019-05-02 19:13:29 +09:00

594 lines
20 KiB
Java

// 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 com.google.common.base.Splitter;
import com.google.common.collect.ListMultimap;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.exceptions.EmailException;
import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.extensions.api.changes.NotifyHandling;
import com.google.gerrit.extensions.api.changes.RecipientType;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.mail.MailHeader;
import com.google.gerrit.reviewdb.client.Account;
import com.google.gerrit.reviewdb.client.Change;
import com.google.gerrit.reviewdb.client.Patch;
import com.google.gerrit.reviewdb.client.PatchSet;
import com.google.gerrit.reviewdb.client.PatchSetInfo;
import com.google.gerrit.reviewdb.client.Project;
import com.google.gerrit.server.StarredChangesUtil;
import com.google.gerrit.server.account.ProjectWatches.NotifyType;
import com.google.gerrit.server.mail.send.ProjectWatch.Watchers;
import com.google.gerrit.server.notedb.ReviewerStateInternal;
import com.google.gerrit.server.patch.PatchList;
import com.google.gerrit.server.patch.PatchListEntry;
import com.google.gerrit.server.patch.PatchListNotAvailableException;
import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
import com.google.gerrit.server.patch.PatchSetInfoNotAvailableException;
import com.google.gerrit.server.permissions.ChangePermission;
import com.google.gerrit.server.permissions.GlobalPermission;
import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.gerrit.server.project.ProjectState;
import com.google.gerrit.server.query.change.ChangeData;
import java.io.IOException;
import java.sql.Timestamp;
import java.text.MessageFormat;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import org.apache.james.mime4j.dom.field.FieldName;
import org.eclipse.jgit.diff.DiffFormatter;
import org.eclipse.jgit.internal.JGitText;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.util.RawParseUtils;
import org.eclipse.jgit.util.TemporaryBuffer;
/** Sends an email to one or more interested parties. */
public abstract class ChangeEmail extends NotificationEmail {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
protected static ChangeData newChangeData(
EmailArguments ea, Project.NameKey project, Change.Id id) {
return ea.changeDataFactory.create(project, id);
}
protected final Change change;
protected final ChangeData changeData;
protected ListMultimap<Account.Id, String> stars;
protected PatchSet patchSet;
protected PatchSetInfo patchSetInfo;
protected String changeMessage;
protected Timestamp timestamp;
protected ProjectState projectState;
protected Set<Account.Id> authors;
protected boolean emailOnlyAuthors;
protected ChangeEmail(EmailArguments ea, String mc, ChangeData cd) {
super(ea, mc, cd.change().getDest());
changeData = cd;
change = cd.change();
emailOnlyAuthors = false;
}
@Override
public void setFrom(Account.Id id) {
super.setFrom(id);
/** Is the from user in an email squelching group? */
try {
args.permissionBackend.absentUser(id).check(GlobalPermission.EMAIL_REVIEWERS);
} catch (AuthException | PermissionBackendException e) {
emailOnlyAuthors = true;
}
}
public void setPatchSet(PatchSet ps) {
patchSet = ps;
}
public void setPatchSet(PatchSet ps, PatchSetInfo psi) {
patchSet = ps;
patchSetInfo = psi;
}
public void setChangeMessage(String cm, Timestamp t) {
changeMessage = cm;
timestamp = t;
}
/** Format the message body by calling {@link #appendText(String)}. */
@Override
protected void format() throws EmailException {
formatChange();
appendText(textTemplate("ChangeFooter"));
if (useHtml()) {
appendHtml(soyHtmlTemplate("ChangeFooterHtml"));
}
formatFooter();
}
/** Format the message body by calling {@link #appendText(String)}. */
protected abstract void formatChange() throws EmailException;
/**
* Format the message footer by calling {@link #appendText(String)}.
*
* @throws EmailException if an error occurred.
*/
protected void formatFooter() throws EmailException {}
/** Setup the message headers and envelope (TO, CC, BCC). */
@Override
protected void init() throws EmailException {
if (args.projectCache != null) {
projectState = args.projectCache.get(change.getProject());
} else {
projectState = null;
}
if (patchSet == null) {
try {
patchSet = changeData.currentPatchSet();
} catch (StorageException err) {
patchSet = null;
}
}
if (patchSet != null) {
setHeader(MailHeader.PATCH_SET.fieldName(), patchSet.getPatchSetId() + "");
if (patchSetInfo == null) {
try {
patchSetInfo = args.patchSetInfoFactory.get(changeData.notes(), patchSet.getId());
} catch (PatchSetInfoNotAvailableException | StorageException err) {
patchSetInfo = null;
}
}
}
authors = getAuthors();
try {
stars = changeData.stars();
} catch (StorageException e) {
throw new EmailException("Failed to load stars for change " + change.getChangeId(), e);
}
super.init();
if (timestamp != null) {
setHeader(FieldName.DATE, new Date(timestamp.getTime()));
}
setChangeSubjectHeader();
setHeader(MailHeader.CHANGE_ID.fieldName(), "" + change.getKey().get());
setHeader(MailHeader.CHANGE_NUMBER.fieldName(), "" + change.getChangeId());
setChangeUrlHeader();
setCommitIdHeader();
if (notify.handling().compareTo(NotifyHandling.OWNER_REVIEWERS) >= 0) {
try {
addByEmail(
RecipientType.CC, changeData.reviewersByEmail().byState(ReviewerStateInternal.CC));
addByEmail(
RecipientType.CC,
changeData.reviewersByEmail().byState(ReviewerStateInternal.REVIEWER));
} catch (StorageException e) {
throw new EmailException("Failed to add unregistered CCs " + change.getChangeId(), e);
}
}
}
private void setChangeUrlHeader() {
final String u = getChangeUrl();
if (u != null) {
setHeader(MailHeader.CHANGE_URL.fieldName(), "<" + u + ">");
}
}
private void setCommitIdHeader() {
if (patchSet != null
&& patchSet.getRevision() != null
&& patchSet.getRevision().get() != null
&& patchSet.getRevision().get().length() > 0) {
setHeader(MailHeader.COMMIT.fieldName(), patchSet.getRevision().get());
}
}
private void setChangeSubjectHeader() {
setHeader(FieldName.SUBJECT, textTemplate("ChangeSubject"));
}
/** Get a link to the change; null if the server doesn't know its own address. */
@Nullable
public String getChangeUrl() {
return args.urlFormatter
.get()
.getChangeViewUrl(change.getProject(), change.getId())
.orElse(null);
}
public String getChangeMessageThreadId() {
return "<gerrit."
+ change.getCreatedOn().getTime()
+ "."
+ change.getKey().get()
+ "@"
+ this.getGerritHost()
+ ">";
}
/** Get the text of the "cover letter". */
public String getCoverLetter() {
if (changeMessage != null) {
return changeMessage.trim();
}
return "";
}
/** Create the change message and the affected file list. */
public String getChangeDetail() {
try {
StringBuilder detail = new StringBuilder();
if (patchSetInfo != null) {
detail.append(patchSetInfo.getMessage().trim()).append("\n");
} else {
detail.append(change.getSubject().trim()).append("\n");
}
if (patchSet != null) {
detail.append("---\n");
PatchList patchList = getPatchList();
for (PatchListEntry p : patchList.getPatches()) {
if (Patch.isMagic(p.getNewName())) {
continue;
}
detail
.append(p.getChangeType().getCode())
.append(" ")
.append(p.getNewName())
.append("\n");
}
detail.append(
MessageFormat.format(
"" //
+ "{0,choice,0#0 files|1#1 file|1<{0} files} changed, " //
+ "{1,choice,0#0 insertions|1#1 insertion|1<{1} insertions}(+), " //
+ "{2,choice,0#0 deletions|1#1 deletion|1<{2} deletions}(-)" //
+ "\n",
patchList.getPatches().size() - 1, //
patchList.getInsertions(), //
patchList.getDeletions()));
detail.append("\n");
}
return detail.toString();
} catch (Exception err) {
logger.atWarning().withCause(err).log("Cannot format change detail");
return "";
}
}
/** Get the patch list corresponding to patch set patchSetId of this change. */
protected PatchList getPatchList(int patchSetId) throws PatchListNotAvailableException {
PatchSet ps;
if (patchSetId == patchSet.getPatchSetId()) {
ps = patchSet;
} else {
try {
ps = args.patchSetUtil.get(changeData.notes(), new PatchSet.Id(change.getId(), patchSetId));
} catch (StorageException e) {
throw new PatchListNotAvailableException("Failed to get patchSet");
}
}
return args.patchListCache.get(change, ps);
}
/** Get the patch list corresponding to this patch set. */
protected PatchList getPatchList() throws PatchListNotAvailableException {
if (patchSet != null) {
return args.patchListCache.get(change, patchSet);
}
throw new PatchListNotAvailableException("no patchSet specified");
}
/** Get the project entity the change is in; null if its been deleted. */
protected ProjectState getProjectState() {
return projectState;
}
/** TO or CC all vested parties (change owner, patch set uploader, author). */
protected void rcptToAuthors(RecipientType rt) {
for (Account.Id id : authors) {
add(rt, id);
}
}
/** BCC any user who has starred this change. */
protected void bccStarredBy() {
if (!NotifyHandling.ALL.equals(notify.handling())) {
return;
}
for (Map.Entry<Account.Id, Collection<String>> e : stars.asMap().entrySet()) {
if (e.getValue().contains(StarredChangesUtil.DEFAULT_LABEL)) {
super.add(RecipientType.BCC, e.getKey());
}
}
}
protected void removeUsersThatIgnoredTheChange() {
for (Map.Entry<Account.Id, Collection<String>> e : stars.asMap().entrySet()) {
if (e.getValue().contains(StarredChangesUtil.IGNORE_LABEL)) {
args.accountCache.get(e.getKey()).ifPresent(a -> removeUser(a.getAccount()));
}
}
}
@Override
protected final Watchers getWatchers(NotifyType type, boolean includeWatchersFromNotifyConfig) {
if (!NotifyHandling.ALL.equals(notify.handling())) {
return new Watchers();
}
ProjectWatch watch = new ProjectWatch(args, branch.getParentKey(), projectState, changeData);
return watch.getWatchers(type, includeWatchersFromNotifyConfig);
}
/** Any user who has published comments on this change. */
protected void ccAllApprovals() {
if (!NotifyHandling.ALL.equals(notify.handling())
&& !NotifyHandling.OWNER_REVIEWERS.equals(notify.handling())) {
return;
}
try {
for (Account.Id id : changeData.reviewers().all()) {
add(RecipientType.CC, id);
}
} catch (StorageException err) {
logger.atWarning().withCause(err).log("Cannot CC users that reviewed updated change");
}
}
/** Users who have non-zero approval codes on the change. */
protected void ccExistingReviewers() {
if (!NotifyHandling.ALL.equals(notify.handling())
&& !NotifyHandling.OWNER_REVIEWERS.equals(notify.handling())) {
return;
}
try {
for (Account.Id id : changeData.reviewers().byState(ReviewerStateInternal.REVIEWER)) {
add(RecipientType.CC, id);
}
} catch (StorageException err) {
logger.atWarning().withCause(err).log("Cannot CC users that commented on updated change");
}
}
@Override
protected void add(RecipientType rt, Account.Id to) {
if (!emailOnlyAuthors || authors.contains(to)) {
super.add(rt, to);
}
}
@Override
protected boolean isVisibleTo(Account.Id to) throws PermissionBackendException {
if (!projectState.statePermitsRead()) {
return false;
}
try {
args.permissionBackend.absentUser(to).change(changeData).check(ChangePermission.READ);
return true;
} catch (AuthException e) {
return false;
}
}
/** Find all users who are authors of any part of this change. */
protected Set<Account.Id> getAuthors() {
Set<Account.Id> authors = new HashSet<>();
switch (notify.handling()) {
case NONE:
break;
case ALL:
default:
if (patchSet != null) {
authors.add(patchSet.getUploader());
}
if (patchSetInfo != null) {
if (patchSetInfo.getAuthor().getAccount() != null) {
authors.add(patchSetInfo.getAuthor().getAccount());
}
if (patchSetInfo.getCommitter().getAccount() != null) {
authors.add(patchSetInfo.getCommitter().getAccount());
}
}
// $FALL-THROUGH$
case OWNER_REVIEWERS:
case OWNER:
authors.add(change.getOwner());
break;
}
return authors;
}
@Override
protected void setupSoyContext() {
super.setupSoyContext();
soyContext.put("changeId", change.getKey().get());
soyContext.put("coverLetter", getCoverLetter());
soyContext.put("fromName", getNameFor(fromId));
soyContext.put("fromEmail", getNameEmailFor(fromId));
soyContext.put("diffLines", getDiffTemplateData());
soyContextEmailData.put("unifiedDiff", getUnifiedDiff());
soyContextEmailData.put("changeDetail", getChangeDetail());
soyContextEmailData.put("changeUrl", getChangeUrl());
soyContextEmailData.put("includeDiff", getIncludeDiff());
Map<String, String> changeData = new HashMap<>();
String subject = change.getSubject();
String originalSubject = change.getOriginalSubject();
changeData.put("subject", subject);
changeData.put("originalSubject", originalSubject);
changeData.put("shortSubject", shortenSubject(subject));
changeData.put("shortOriginalSubject", shortenSubject(originalSubject));
changeData.put("ownerName", getNameFor(change.getOwner()));
changeData.put("ownerEmail", getNameEmailFor(change.getOwner()));
changeData.put("changeNumber", Integer.toString(change.getChangeId()));
soyContext.put("change", changeData);
Map<String, Object> patchSetData = new HashMap<>();
patchSetData.put("patchSetId", patchSet.getPatchSetId());
patchSetData.put("refName", patchSet.getRefName());
soyContext.put("patchSet", patchSetData);
Map<String, Object> patchSetInfoData = new HashMap<>();
patchSetInfoData.put("authorName", patchSetInfo.getAuthor().getName());
patchSetInfoData.put("authorEmail", patchSetInfo.getAuthor().getEmail());
soyContext.put("patchSetInfo", patchSetInfoData);
footers.add(MailHeader.CHANGE_ID.withDelimiter() + change.getKey().get());
footers.add(MailHeader.CHANGE_NUMBER.withDelimiter() + Integer.toString(change.getChangeId()));
footers.add(MailHeader.PATCH_SET.withDelimiter() + patchSet.getPatchSetId());
footers.add(MailHeader.OWNER.withDelimiter() + getNameEmailFor(change.getOwner()));
if (change.getAssignee() != null) {
footers.add(MailHeader.ASSIGNEE.withDelimiter() + getNameEmailFor(change.getAssignee()));
}
for (String reviewer : getEmailsByState(ReviewerStateInternal.REVIEWER)) {
footers.add(MailHeader.REVIEWER.withDelimiter() + reviewer);
}
for (String reviewer : getEmailsByState(ReviewerStateInternal.CC)) {
footers.add(MailHeader.CC.withDelimiter() + reviewer);
}
}
/**
* A shortened subject is the subject limited to 72 characters, with an ellipsis if it exceeds
* that limit.
*/
private static String shortenSubject(String subject) {
if (subject.length() < 73) {
return subject;
}
return subject.substring(0, 69) + "...";
}
private Set<String> getEmailsByState(ReviewerStateInternal state) {
Set<String> reviewers = new TreeSet<>();
try {
for (Account.Id who : changeData.reviewers().byState(state)) {
reviewers.add(getNameEmailFor(who));
}
} catch (StorageException e) {
logger.atWarning().withCause(e).log("Cannot get change reviewers");
}
return reviewers;
}
public boolean getIncludeDiff() {
return args.settings.includeDiff;
}
private static final int HEAP_EST_SIZE = 32 * 1024;
/** Show patch set as unified difference. */
public String getUnifiedDiff() {
PatchList patchList;
try {
patchList = getPatchList();
if (patchList.getOldId() == null) {
// Octopus merges are not well supported for diff output by Gerrit.
// Currently these always have a null oldId in the PatchList.
return "[Octopus merge; cannot be formatted as a diff.]\n";
}
} catch (PatchListObjectTooLargeException e) {
logger.atWarning().log("Cannot format patch %s", e.getMessage());
return "";
} catch (PatchListNotAvailableException e) {
logger.atSevere().withCause(e).log("Cannot format patch");
return "";
}
int maxSize = args.settings.maximumDiffSize;
TemporaryBuffer.Heap buf = new TemporaryBuffer.Heap(Math.min(HEAP_EST_SIZE, maxSize), maxSize);
try (DiffFormatter fmt = new DiffFormatter(buf)) {
try (Repository git = args.server.openRepository(change.getProject())) {
try {
fmt.setRepository(git);
fmt.setDetectRenames(true);
fmt.format(patchList.getOldId(), patchList.getNewId());
return RawParseUtils.decode(buf.toByteArray());
} catch (IOException e) {
if (JGitText.get().inMemoryBufferLimitExceeded.equals(e.getMessage())) {
return "";
}
logger.atSevere().withCause(e).log("Cannot format patch");
return "";
}
} catch (IOException e) {
logger.atSevere().withCause(e).log("Cannot open repository to format patch");
return "";
}
}
}
/**
* Generate a Soy list of maps representing each line of the unified diff. The line maps will have
* a 'type' key which maps to one of 'common', 'add' or 'remove' and a 'text' key which maps to
* the line's content.
*/
private List<Map<String, String>> getDiffTemplateData() {
List<Map<String, String>> result = new ArrayList<>();
Splitter lineSplitter = Splitter.on(System.getProperty("line.separator"));
for (String diffLine : lineSplitter.split(getUnifiedDiff())) {
Map<String, String> lineData = new HashMap<>();
lineData.put("text", diffLine);
// Skip empty lines and lines that look like diff headers.
if (diffLine.isEmpty() || diffLine.startsWith("---") || diffLine.startsWith("+++")) {
lineData.put("type", "common");
} else {
switch (diffLine.charAt(0)) {
case '+':
lineData.put("type", "add");
break;
case '-':
lineData.put("type", "remove");
break;
default:
lineData.put("type", "common");
break;
}
}
result.add(lineData);
}
return result;
}
}