summaryrefslogtreecommitdiffstats
path: root/java/com/google/gerrit/server/mail/send/ChangeEmail.java
diff options
context:
space:
mode:
Diffstat (limited to 'java/com/google/gerrit/server/mail/send/ChangeEmail.java')
-rw-r--r--java/com/google/gerrit/server/mail/send/ChangeEmail.java599
1 files changed, 599 insertions, 0 deletions
diff --git a/java/com/google/gerrit/server/mail/send/ChangeEmail.java b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
new file mode 100644
index 0000000000..8b8f6f9fb4
--- /dev/null
+++ b/java/com/google/gerrit/server/mail/send/ChangeEmail.java
@@ -0,0 +1,599 @@
+// 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.common.errors.EmailException;
+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 com.google.gwtorm.server.OrmException;
+import com.google.template.soy.data.SoyListData;
+import com.google.template.soy.data.SoyMapData;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.text.MessageFormat;
+import java.util.Collection;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.HashSet;
+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(ea.db.get(), 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) throws OrmException {
+ 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 (OrmException err) {
+ patchSet = null;
+ }
+ }
+
+ if (patchSet != null) {
+ setHeader(MailHeader.PATCH_SET.fieldName(), patchSet.getPatchSetId() + "");
+ if (patchSetInfo == null) {
+ try {
+ patchSetInfo =
+ args.patchSetInfoFactory.get(args.db.get(), changeData.notes(), patchSet.getId());
+ } catch (PatchSetInfoNotAvailableException | OrmException err) {
+ patchSetInfo = null;
+ }
+ }
+ }
+ authors = getAuthors();
+
+ try {
+ stars = changeData.stars();
+ } catch (OrmException 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.ordinal() >= NotifyHandling.OWNER_REVIEWERS.ordinal()) {
+ try {
+ addByEmail(
+ RecipientType.CC, changeData.reviewersByEmail().byState(ReviewerStateInternal.CC));
+ addByEmail(
+ RecipientType.CC,
+ changeData.reviewersByEmail().byState(ReviewerStateInternal.REVIEWER));
+ } catch (OrmException 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.db(), changeData.notes(), new PatchSet.Id(change.getId(), patchSetId));
+ } catch (OrmException 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)) {
+ 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)
+ throws OrmException {
+ if (!NotifyHandling.ALL.equals(notify)) {
+ 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) && !NotifyHandling.OWNER_REVIEWERS.equals(notify)) {
+ return;
+ }
+
+ try {
+ for (Account.Id id : changeData.reviewers().all()) {
+ add(RecipientType.CC, id);
+ }
+ } catch (OrmException 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) && !NotifyHandling.OWNER_REVIEWERS.equals(notify)) {
+ return;
+ }
+
+ try {
+ for (Account.Id id : changeData.reviewers().byState(ReviewerStateInternal.REVIEWER)) {
+ add(RecipientType.CC, id);
+ }
+ } catch (OrmException 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)
+ .database(args.db)
+ .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) {
+ 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 (OrmException 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 SoyListData getDiffTemplateData() {
+ SoyListData result = new SoyListData();
+ Splitter lineSplitter = Splitter.on(System.getProperty("line.separator"));
+ for (String diffLine : lineSplitter.split(getUnifiedDiff())) {
+ SoyMapData lineData = new SoyMapData();
+ 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;
+ }
+}