blob: ffe0c0c3e3be5ac62fde1ea6d8686bc1dd1fe20b [file] [log] [blame]
// 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.receive;
import com.google.common.base.Strings;
import com.google.gerrit.common.TimeUtil;
import com.google.gerrit.extensions.client.Side;
import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
import com.google.gerrit.reviewdb.client.Account;
import com.google.gerrit.reviewdb.client.Change;
import com.google.gerrit.reviewdb.client.ChangeMessage;
import com.google.gerrit.reviewdb.client.Comment;
import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
import com.google.gerrit.reviewdb.client.PatchSet;
import com.google.gerrit.reviewdb.client.Project;
import com.google.gerrit.reviewdb.server.ReviewDb;
import com.google.gerrit.server.ChangeMessagesUtil;
import com.google.gerrit.server.CommentsUtil;
import com.google.gerrit.server.PatchSetUtil;
import com.google.gerrit.server.account.AccountByEmailCache;
import com.google.gerrit.server.config.CanonicalWebUrl;
import com.google.gerrit.server.git.BatchUpdate;
import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
import com.google.gerrit.server.git.UpdateException;
import com.google.gerrit.server.patch.PatchListCache;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.gerrit.server.query.change.InternalChangeQuery;
import com.google.gerrit.server.util.ManualRequestContext;
import com.google.gerrit.server.util.OneOffRequestContext;
import com.google.gwtorm.server.OrmException;
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.Singleton;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
@Singleton
public class MailProcessor {
private static final Logger log =
LoggerFactory.getLogger(MailProcessor.class.getName());
private final AccountByEmailCache accountByEmailCache;
private final BatchUpdate.Factory buf;
private final ChangeMessagesUtil changeMessagesUtil;
private final CommentsUtil commentsUtil;
private final OneOffRequestContext oneOffRequestContext;
private final PatchListCache patchListCache;
private final PatchSetUtil psUtil;
private final Provider<InternalChangeQuery> queryProvider;
private final Provider<ReviewDb> reviewDb;
private final Provider<String> canonicalUrl;
@Inject
public MailProcessor(AccountByEmailCache accountByEmailCache,
BatchUpdate.Factory buf,
ChangeMessagesUtil changeMessagesUtil,
CommentsUtil commentsUtil,
OneOffRequestContext oneOffRequestContext,
PatchListCache patchListCache,
PatchSetUtil psUtil,
Provider<InternalChangeQuery> queryProvider,
Provider<ReviewDb> reviewDb,
@CanonicalWebUrl Provider<String> canonicalUrl) {
this.accountByEmailCache = accountByEmailCache;
this.buf = buf;
this.changeMessagesUtil = changeMessagesUtil;
this.commentsUtil = commentsUtil;
this.oneOffRequestContext = oneOffRequestContext;
this.patchListCache = patchListCache;
this.psUtil = psUtil;
this.queryProvider = queryProvider;
this.reviewDb = reviewDb;
this.canonicalUrl = canonicalUrl;
}
/**
* Parse comments from MailMessage and persist them on the change.
* @param message MailMessage to process.
* @throws OrmException
*/
public void process(MailMessage message) throws OrmException {
MailMetadata metadata = MetadataParser.parse(message);
if (!metadata.hasRequiredFields()) {
log.error("Mail: Message " + message.id() +
" is missing required metadata, have " + metadata +
". Will delete message.");
return;
}
Set<Account.Id> accounts = accountByEmailCache.get(metadata.author);
if (accounts.size() != 1) {
log.error("Mail: Address " + metadata.author +
" could not be matched to a unique account. It was matched to " +
accounts + ". Will delete message.");
return;
}
Account.Id account = accounts.iterator().next();
if (!reviewDb.get().accounts().get(account).isActive()) {
log.warn("Mail: Account " + account +
" is inactive. Will delete message.");
return;
}
try (ManualRequestContext ctx = oneOffRequestContext.openAs(account)) {
ChangeData cd = queryProvider.get().setLimit(1)
.byKey(Change.Key.parse(metadata.changeId)).get(0);
if (existingMessageIds(cd).contains(message.id())) {
log.info("Mail: Message " + message.id() +
" was already processed. Will delete message.");
return;
}
// Get all comments; filter and sort them to get the original list of
// comments from the outbound email.
// TODO(hiesel) Also filter by original comment author.
Collection<Comment> comments = cd.publishedComments().stream()
.filter(c -> (c.writtenOn.getTime() / 1000) ==
(metadata.timestamp.getTime() / 1000))
.sorted(CommentsUtil.COMMENT_ORDER)
.collect(Collectors.toList());
Project.NameKey project = cd.project();
String changeUrl = canonicalUrl.get() + "#/c/" + cd.getId().get();
List<MailComment> parsedComments;
if (useHtmlParser(message)) {
parsedComments = HtmlParser.parse(message, comments, changeUrl);
} else {
parsedComments = TextParser.parse(message, comments, changeUrl);
}
if (parsedComments.isEmpty()) {
log.warn("Mail: Could not parse any comments from " + message.id() +
". Will delete message.");
return;
}
Op o = new Op(new PatchSet.Id(cd.getId(), metadata.patchSet),
parsedComments, message.id());
BatchUpdate batchUpdate = buf.create(cd.db(), project, ctx.getUser(),
TimeUtil.nowTs());
batchUpdate.addOp(cd.getId(), o);
try {
batchUpdate.execute();
} catch (UpdateException | RestApiException e) {
throw new OrmException(e);
}
}
}
private class Op extends BatchUpdate.Op {
private final PatchSet.Id psId;
private final List<MailComment> parsedComments;
private final String tag;
private Op(PatchSet.Id psId, List<MailComment> parsedComments,
String messageId) {
this.psId = psId;
this.parsedComments = parsedComments;
this.tag = "mailMessageId=" + messageId;
}
@Override
public boolean updateChange(ChangeContext ctx) throws OrmException,
UnprocessableEntityException {
PatchSet ps = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
if (ps == null) {
throw new OrmException("patch set not found: " + psId);
}
String changeMsg = "Patch Set " + psId.get() + ":";
if (parsedComments.get(0).type ==
MailComment.CommentType.CHANGE_MESSAGE) {
if (parsedComments.size() > 1) {
changeMsg += "\n" + numComments(parsedComments.size() - 1);
}
changeMsg += "\n" + parsedComments.get(0).message;
} else {
changeMsg += "\n" + numComments(parsedComments.size());
}
ChangeMessage msg = ChangeMessagesUtil.newMessage(ctx, changeMsg, tag);
changeMessagesUtil.addChangeMessage(ctx.getDb(),
ctx.getUpdate(psId), msg);
List<Comment> comments = new ArrayList<>();
for (MailComment c : parsedComments) {
if (c.type == MailComment.CommentType.CHANGE_MESSAGE) {
continue;
}
String fileName;
// The patch set that this comment is based on is different if this
// comment was sent in reply to a comment on a previous patch set.
PatchSet psForComment;
Side side;
if (c.inReplyTo != null) {
fileName = c.inReplyTo.key.filename;
psForComment = psUtil.get(ctx.getDb(), ctx.getNotes(),
new PatchSet.Id(ctx.getChange().getId(),
c.inReplyTo.key.patchSetId));
side = Side.fromShort(c.inReplyTo.side);
} else {
fileName = c.fileName;
psForComment = ps;
side = Side.REVISION;
}
Comment comment = commentsUtil.newComment(ctx, fileName,
psForComment.getId(), (short) side.ordinal(), c.message,
false, null);
comment.tag = tag;
if (c.inReplyTo != null) {
comment.parentUuid = c.inReplyTo.key.uuid;
comment.lineNbr = c.inReplyTo.lineNbr;
comment.range = c.inReplyTo.range;
comment.unresolved = c.inReplyTo.unresolved;
}
CommentsUtil.setCommentRevId(comment, patchListCache,
ctx.getChange(), psForComment);
comments.add(comment);
}
commentsUtil.putComments(ctx.getDb(),
ctx.getUpdate(ctx.getChange().currentPatchSetId()), Status.PUBLISHED,
comments);
return true;
}
}
private static boolean useHtmlParser(MailMessage m) {
return !Strings.isNullOrEmpty(m.htmlContent());
}
private static String numComments(int numComments) {
return "(" + numComments + (numComments > 1 ? " comments)" : " comment)");
}
private Set<String> existingMessageIds(ChangeData cd) throws OrmException {
Set<String> existingMessageIds = new HashSet<>();
cd.messages().stream().forEach(m -> {
String messageId = CommentsUtil.extractMessageId(m.getTag());
if (messageId != null) {
existingMessageIds.add(messageId);
}
});
cd.publishedComments().stream().forEach(c -> {
String messageId = CommentsUtil.extractMessageId(c.tag);
if (messageId != null) {
existingMessageIds.add(messageId);
}
});
return existingMessageIds;
}
}