Merge "PostReview: Send batch event when multiple reviewers are added at once"
diff --git a/java/com/google/gerrit/acceptance/ExtensionRegistry.java b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
index 85c4c13..1e5598e 100644
--- a/java/com/google/gerrit/acceptance/ExtensionRegistry.java
+++ b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
@@ -25,6 +25,8 @@
import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
import com.google.gerrit.extensions.events.GroupIndexedListener;
import com.google.gerrit.extensions.events.ProjectIndexedListener;
+import com.google.gerrit.extensions.events.ReviewerAddedListener;
+import com.google.gerrit.extensions.events.ReviewerDeletedListener;
import com.google.gerrit.extensions.events.RevisionCreatedListener;
import com.google.gerrit.extensions.events.TopicEditedListener;
import com.google.gerrit.extensions.events.WorkInProgressStateChangedListener;
@@ -91,6 +93,8 @@
private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
private final DynamicSet<PluginPushOption> pluginPushOptions;
private final DynamicSet<OnPostReview> onPostReviews;
+ private final DynamicSet<ReviewerAddedListener> reviewerAddedListeners;
+ private final DynamicSet<ReviewerDeletedListener> reviewerDeletedListeners;
@Inject
ExtensionRegistry(
@@ -125,7 +129,9 @@
DynamicMap<PluginProjectPermissionDefinition> pluginProjectPermissionDefinitions,
DynamicMap<ProjectConfigEntry> pluginConfigEntries,
DynamicSet<PluginPushOption> pluginPushOption,
- DynamicSet<OnPostReview> onPostReviews) {
+ DynamicSet<OnPostReview> onPostReviews,
+ DynamicSet<ReviewerAddedListener> reviewerAddedListeners,
+ DynamicSet<ReviewerDeletedListener> reviewerDeletedListeners) {
this.accountIndexedListeners = accountIndexedListeners;
this.changeIndexedListeners = changeIndexedListeners;
this.groupIndexedListeners = groupIndexedListeners;
@@ -158,6 +164,8 @@
this.pluginConfigEntries = pluginConfigEntries;
this.pluginPushOptions = pluginPushOption;
this.onPostReviews = onPostReviews;
+ this.reviewerAddedListeners = reviewerAddedListeners;
+ this.reviewerDeletedListeners = reviewerDeletedListeners;
}
public Registration newRegistration() {
@@ -302,6 +310,14 @@
return add(onPostReviews, onPostReview);
}
+ public Registration add(ReviewerAddedListener reviewerAddedListener) {
+ return add(reviewerAddedListeners, reviewerAddedListener);
+ }
+
+ public Registration add(ReviewerDeletedListener reviewerDeletedListener) {
+ return add(reviewerDeletedListeners, reviewerDeletedListener);
+ }
+
private <T> Registration add(DynamicSet<T> dynamicSet, T extension) {
return add(dynamicSet, extension, "gerrit");
}
diff --git a/java/com/google/gerrit/server/change/AddReviewersOp.java b/java/com/google/gerrit/server/change/AddReviewersOp.java
index a333ce5..cbbd01a 100644
--- a/java/com/google/gerrit/server/change/AddReviewersOp.java
+++ b/java/com/google/gerrit/server/change/AddReviewersOp.java
@@ -219,8 +219,13 @@
.map(r -> accountCache.get(r.accountId()))
.flatMap(Streams::stream)
.collect(toList());
- reviewerAdded.fire(
- ctx.getChangeData(change), patchSet, reviewers, ctx.getAccount(), ctx.getWhen());
+ eventSender =
+ () ->
+ reviewerAdded.fire(
+ ctx.getChangeData(change), patchSet, reviewers, ctx.getAccount(), ctx.getWhen());
+ if (sendEvent) {
+ sendEvent();
+ }
}
}
}
diff --git a/java/com/google/gerrit/server/change/DeleteReviewerOp.java b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
index a26f107..1e40429 100644
--- a/java/com/google/gerrit/server/change/DeleteReviewerOp.java
+++ b/java/com/google/gerrit/server/change/DeleteReviewerOp.java
@@ -202,16 +202,23 @@
"Cannot email update for change %s", currChange.getId());
}
}
- reviewerDeleted.fire(
- ctx.getChangeData(currChange),
- patchSet,
- accountCache.get(reviewer.id()).orElse(AccountState.forAccount(reviewer)),
- ctx.getAccount(),
- mailMessage,
- newApprovals,
- oldApprovals,
- notify.handling(),
- ctx.getWhen());
+
+ NotifyHandling notifyHandling = notify.handling();
+ eventSender =
+ () ->
+ reviewerDeleted.fire(
+ ctx.getChangeData(currChange),
+ patchSet,
+ accountCache.get(reviewer.id()).orElse(AccountState.forAccount(reviewer)),
+ ctx.getAccount(),
+ mailMessage,
+ newApprovals,
+ oldApprovals,
+ notifyHandling,
+ ctx.getWhen());
+ if (sendEvent) {
+ sendEvent();
+ }
}
private Iterable<PatchSetApproval> approvals(ChangeContext ctx, Account.Id accountId) {
diff --git a/java/com/google/gerrit/server/change/ReviewerOp.java b/java/com/google/gerrit/server/change/ReviewerOp.java
index 716ac5e..12227c2 100644
--- a/java/com/google/gerrit/server/change/ReviewerOp.java
+++ b/java/com/google/gerrit/server/change/ReviewerOp.java
@@ -32,6 +32,8 @@
public class ReviewerOp implements BatchUpdateOp {
protected boolean sendEmail = true;
+ protected boolean sendEvent = true;
+ protected Runnable eventSender = () -> {};
protected PatchSet patchSet;
protected Result opResult;
@@ -42,6 +44,14 @@
this.sendEmail = false;
}
+ public void suppressEvent() {
+ this.sendEvent = false;
+ }
+
+ public void sendEvent() {
+ eventSender.run();
+ }
+
void setPatchSet(PatchSet patchSet) {
this.patchSet = requireNonNull(patchSet);
}
diff --git a/java/com/google/gerrit/server/restapi/change/PostReview.java b/java/com/google/gerrit/server/restapi/change/PostReview.java
index 5002a82..5c252f4 100644
--- a/java/com/google/gerrit/server/restapi/change/PostReview.java
+++ b/java/com/google/gerrit/server/restapi/change/PostReview.java
@@ -92,7 +92,9 @@
import com.google.gerrit.server.PatchSetUtil;
import com.google.gerrit.server.PublishCommentUtil;
import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.account.AccountCache;
import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.account.AccountState;
import com.google.gerrit.server.approval.ApprovalsUtil;
import com.google.gerrit.server.change.ChangeResource;
import com.google.gerrit.server.change.EmailReviewComments;
@@ -105,6 +107,7 @@
import com.google.gerrit.server.change.WorkInProgressOp;
import com.google.gerrit.server.config.GerritServerConfig;
import com.google.gerrit.server.extensions.events.CommentAdded;
+import com.google.gerrit.server.extensions.events.ReviewerAdded;
import com.google.gerrit.server.logging.Metadata;
import com.google.gerrit.server.logging.TraceContext;
import com.google.gerrit.server.notedb.ChangeNotes;
@@ -187,6 +190,7 @@
private final BatchUpdate.Factory updateFactory;
private final ChangeResource.Factory changeResourceFactory;
private final ChangeData.Factory changeDataFactory;
+ private final AccountCache accountCache;
private final ApprovalsUtil approvalsUtil;
private final ChangeMessagesUtil cmUtil;
private final CommentsUtil commentsUtil;
@@ -206,6 +210,7 @@
private final PluginSetContext<CommentValidator> commentValidators;
private final PluginSetContext<OnPostReview> onPostReviews;
private final ReplyAttentionSetUpdates replyAttentionSetUpdates;
+ private final ReviewerAdded reviewerAdded;
private final boolean strictLabels;
private final boolean publishPatchSetLevelComment;
@@ -214,6 +219,7 @@
BatchUpdate.Factory updateFactory,
ChangeResource.Factory changeResourceFactory,
ChangeData.Factory changeDataFactory,
+ AccountCache accountCache,
ApprovalsUtil approvalsUtil,
ChangeMessagesUtil cmUtil,
CommentsUtil commentsUtil,
@@ -233,10 +239,12 @@
PermissionBackend permissionBackend,
PluginSetContext<CommentValidator> commentValidators,
PluginSetContext<OnPostReview> onPostReviews,
- ReplyAttentionSetUpdates replyAttentionSetUpdates) {
+ ReplyAttentionSetUpdates replyAttentionSetUpdates,
+ ReviewerAdded reviewerAdded) {
this.updateFactory = updateFactory;
this.changeResourceFactory = changeResourceFactory;
this.changeDataFactory = changeDataFactory;
+ this.accountCache = accountCache;
this.commentsUtil = commentsUtil;
this.publishCommentUtil = publishCommentUtil;
this.psUtil = psUtil;
@@ -256,6 +264,7 @@
this.commentValidators = commentValidators;
this.onPostReviews = onPostReviews;
this.replyAttentionSetUpdates = replyAttentionSetUpdates;
+ this.reviewerAdded = reviewerAdded;
this.strictLabels = gerritConfig.getBoolean("change", "strictLabels", false);
this.publishPatchSetLevelComment =
gerritConfig.getBoolean("event", "comment-added", "publishPatchSetLevelComment", true);
@@ -371,6 +380,7 @@
logger.atFine().log("adding reviewer additions");
for (ReviewerModification reviewerResult : reviewerResults) {
reviewerResult.op.suppressEmail(); // Send a single batch email below.
+ reviewerResult.op.suppressEvent(); // Send events below, if possible as batch.
bu.addOp(revision.getChange().getId(), reviewerResult.op);
if (!ccOrReviewer && reviewerResult.reviewers.contains(account)) {
logger.atFine().log("calling user is explicitly added as reviewer or CC");
@@ -386,6 +396,7 @@
ReviewerModification selfAddition =
reviewerModifier.ccCurrentUser(revision.getUser(), revision);
selfAddition.op.suppressEmail();
+ selfAddition.op.suppressEvent();
bu.addOp(revision.getChange().getId(), selfAddition.op);
}
@@ -433,8 +444,10 @@
reviewerResult.gatherResults(cd);
}
- // Sending from AddReviewersOp was suppressed so we can send a single batch email here.
+ // Sending emails and events from ReviewersOps was suppressed so we can send a single batch
+ // email/event here.
batchEmailReviewers(revision.getUser(), revision.getChange(), reviewerResults, notify);
+ batchReviewerEvents(revision.getUser(), cd, revision.getPatchSet(), reviewerResults, ts);
}
return Response.ok(output);
@@ -512,6 +525,35 @@
}
}
+ private void batchReviewerEvents(
+ CurrentUser user,
+ ChangeData cd,
+ PatchSet patchSet,
+ List<ReviewerModification> reviewerModifications,
+ Timestamp when) {
+ List<AccountState> newlyAddedReviewers = new ArrayList<>();
+
+ // There are no events for CCs and reviewers added/deleted by email.
+ for (ReviewerModification modification : reviewerModifications) {
+ Result reviewerAdditionResult = modification.op.getResult();
+ if (modification.state() == ReviewerState.REVIEWER) {
+ newlyAddedReviewers.addAll(
+ reviewerAdditionResult.addedReviewers().stream()
+ .map(psa -> psa.accountId())
+ .map(accountId -> accountCache.get(accountId))
+ .flatMap(Streams::stream)
+ .collect(toList()));
+ } else if (modification.state() == ReviewerState.REMOVED) {
+ // There is no batch event for reviewer removals, hence fire the event for each
+ // modification that deleted a reviewer immediately.
+ modification.op.sendEvent();
+ }
+ }
+
+ // Fire a batch event for all newly added reviewers.
+ reviewerAdded.fire(cd, patchSet, newlyAddedReviewers, user.asIdentifiedUser().state(), when);
+ }
+
private RevisionResource onBehalfOf(RevisionResource rev, LabelTypes labelTypes, ReviewInput in)
throws BadRequestException, AuthException, UnprocessableEntityException,
PermissionBackendException, IOException, ConfigInvalidException {
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
index b79be80..96bc65d 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
@@ -27,6 +27,7 @@
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Iterables;
import com.google.common.truth.Correspondence;
import com.google.gerrit.acceptance.AbstractDaemonTest;
@@ -56,6 +57,8 @@
import com.google.gerrit.extensions.common.RobotCommentInfo;
import com.google.gerrit.extensions.config.FactoryModule;
import com.google.gerrit.extensions.events.CommentAddedListener;
+import com.google.gerrit.extensions.events.ReviewerAddedListener;
+import com.google.gerrit.extensions.events.ReviewerDeletedListener;
import com.google.gerrit.extensions.restapi.BadRequestException;
import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
import com.google.gerrit.extensions.restapi.RestApiException;
@@ -74,6 +77,7 @@
import com.google.inject.Inject;
import com.google.inject.Module;
import java.sql.Timestamp;
+import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
@@ -699,6 +703,61 @@
}
@Test
+ public void addingReviewers() throws Exception {
+ PushOneCommit.Result r = createChange();
+
+ TestAccount user2 = accountCreator.user2();
+
+ TestReviewerAddedListener testReviewerAddedListener = new TestReviewerAddedListener();
+ try (Registration registration =
+ extensionRegistry.newRegistration().add(testReviewerAddedListener)) {
+ // add user and user2
+ ReviewResult reviewResult =
+ gApi.changes()
+ .id(r.getChangeId())
+ .current()
+ .review(ReviewInput.create().reviewer(user.email()).reviewer(user2.email()));
+
+ assertThat(
+ reviewResult.reviewers.values().stream()
+ .filter(a -> a.reviewers != null)
+ .map(a -> Iterables.getOnlyElement(a.reviewers).name)
+ .collect(toImmutableSet()))
+ .containsExactly(user.fullName(), user2.fullName());
+ }
+
+ assertThat(
+ gApi.changes().id(r.getChangeId()).reviewers().stream()
+ .map(a -> a.name)
+ .collect(toImmutableSet()))
+ .containsExactly(user.fullName(), user2.fullName());
+
+ // Ensure only one batch email was sent for this operation
+ FakeEmailSender.Message message = Iterables.getOnlyElement(sender.getMessages());
+ assertThat(message.body())
+ .containsMatch(
+ Pattern.quote("Hello ")
+ + "("
+ + Pattern.quote(String.format("%s, %s", user.fullName(), user2.fullName()))
+ + "|"
+ + Pattern.quote(String.format("%s, %s", user2.fullName(), user.fullName()))
+ + ")");
+ assertThat(message.htmlBody())
+ .containsMatch(
+ "("
+ + Pattern.quote(String.format("%s and %s", user.fullName(), user2.fullName()))
+ + "|"
+ + Pattern.quote(String.format("%s and %s", user2.fullName(), user.fullName()))
+ + ")"
+ + Pattern.quote(" to <strong>review</strong> this change"));
+
+ // Ensure that a batch event has been sent:
+ // * 1 batch event for adding user and user2 as reviewers
+ assertThat(testReviewerAddedListener.receivedEvents).hasSize(1);
+ assertThat(testReviewerAddedListener.getReviewerIds()).containsExactly(user.id(), user2.id());
+ }
+
+ @Test
public void deletingReviewers() throws Exception {
PushOneCommit.Result r = createChange();
@@ -712,21 +771,25 @@
sender.clear();
- // remove user and user2
- ReviewResult reviewResult =
- gApi.changes()
- .id(r.getChangeId())
- .current()
- .review(
- ReviewInput.create()
- .reviewer(user.email(), ReviewerState.REMOVED, /* confirmed= */ true)
- .reviewer(user2.email(), ReviewerState.REMOVED, /* confirmed= */ true));
+ TestReviewerDeletedListener testReviewerDeletedListener = new TestReviewerDeletedListener();
+ try (Registration registration =
+ extensionRegistry.newRegistration().add(testReviewerDeletedListener)) {
+ // remove user and user2
+ ReviewResult reviewResult =
+ gApi.changes()
+ .id(r.getChangeId())
+ .current()
+ .review(
+ ReviewInput.create()
+ .reviewer(user.email(), ReviewerState.REMOVED, /* confirmed= */ true)
+ .reviewer(user2.email(), ReviewerState.REMOVED, /* confirmed= */ true));
- assertThat(
- reviewResult.reviewers.values().stream()
- .map(a -> a.removed.name)
- .collect(toImmutableSet()))
- .containsExactly(user.fullName(), user2.fullName());
+ assertThat(
+ reviewResult.reviewers.values().stream()
+ .map(a -> a.removed.name)
+ .collect(toImmutableSet()))
+ .containsExactly(user.fullName(), user2.fullName());
+ }
assertThat(gApi.changes().id(r.getChangeId()).reviewers()).isEmpty();
@@ -748,6 +811,12 @@
+ "|"
+ Pattern.quote(String.format("%s and %s", user2.fullName(), user.fullName()))
+ ")");
+
+ // Ensure that events have been sent:
+ // * 2 events for removing user and user2 as reviewers (one event per removed reviewer, batch
+ // event not available for reviewer removal)
+ assertThat(testReviewerDeletedListener.receivedEvents).hasSize(2);
+ assertThat(testReviewerDeletedListener.getReviewerIds()).containsExactly(user.id(), user2.id());
}
@Test
@@ -766,30 +835,38 @@
sender.clear();
- // remove user and user2 while adding user3 and user4
- ReviewResult reviewResult =
- gApi.changes()
- .id(r.getChangeId())
- .current()
- .review(
- ReviewInput.create()
- .reviewer(user.email(), ReviewerState.REMOVED, /* confirmed= */ true)
- .reviewer(user2.email(), ReviewerState.REMOVED, /* confirmed= */ true)
- .reviewer(user3.email())
- .reviewer(user4.email()));
+ TestReviewerAddedListener testReviewerAddedListener = new TestReviewerAddedListener();
+ TestReviewerDeletedListener testReviewerDeletedListener = new TestReviewerDeletedListener();
+ try (Registration registration =
+ extensionRegistry
+ .newRegistration()
+ .add(testReviewerAddedListener)
+ .add(testReviewerDeletedListener)) {
+ // remove user and user2 while adding user3 and user4
+ ReviewResult reviewResult =
+ gApi.changes()
+ .id(r.getChangeId())
+ .current()
+ .review(
+ ReviewInput.create()
+ .reviewer(user.email(), ReviewerState.REMOVED, /* confirmed= */ true)
+ .reviewer(user2.email(), ReviewerState.REMOVED, /* confirmed= */ true)
+ .reviewer(user3.email())
+ .reviewer(user4.email()));
- assertThat(
- reviewResult.reviewers.values().stream()
- .filter(a -> a.removed != null)
- .map(a -> a.removed.name)
- .collect(toImmutableSet()))
- .containsExactly(user.fullName(), user2.fullName());
- assertThat(
- reviewResult.reviewers.values().stream()
- .filter(a -> a.reviewers != null)
- .map(a -> Iterables.getOnlyElement(a.reviewers).name)
- .collect(toImmutableSet()))
- .containsExactly(user3.fullName(), user4.fullName());
+ assertThat(
+ reviewResult.reviewers.values().stream()
+ .filter(a -> a.removed != null)
+ .map(a -> a.removed.name)
+ .collect(toImmutableSet()))
+ .containsExactly(user.fullName(), user2.fullName());
+ assertThat(
+ reviewResult.reviewers.values().stream()
+ .filter(a -> a.reviewers != null)
+ .map(a -> Iterables.getOnlyElement(a.reviewers).name)
+ .collect(toImmutableSet()))
+ .containsExactly(user3.fullName(), user4.fullName());
+ }
assertThat(
gApi.changes().id(r.getChangeId()).reviewers().stream()
@@ -832,6 +909,15 @@
+ "|"
+ Pattern.quote(String.format("%s and %s", user2.fullName(), user.fullName()))
+ ")");
+
+ // Ensure that events have been sent:
+ // * 1 batch event for adding user3 and user4 as reviewers
+ // * 2 events for removing user and user2 as reviewers (one event per removed reviewer, batch
+ // event not available for reviewer removal)
+ assertThat(testReviewerAddedListener.receivedEvents).hasSize(1);
+ assertThat(testReviewerAddedListener.getReviewerIds()).containsExactly(user3.id(), user4.id());
+ assertThat(testReviewerDeletedListener.receivedEvents).hasSize(2);
+ assertThat(testReviewerDeletedListener.getReviewerIds()).containsExactly(user.id(), user2.id());
}
@Test
@@ -964,4 +1050,36 @@
return Optional.empty();
}
}
+
+ private static class TestReviewerAddedListener implements ReviewerAddedListener {
+ List<ReviewerAddedListener.Event> receivedEvents = new ArrayList<>();
+
+ @Override
+ public void onReviewersAdded(ReviewerAddedListener.Event event) {
+ receivedEvents.add(event);
+ }
+
+ public ImmutableSet<Account.Id> getReviewerIds() {
+ return receivedEvents.stream()
+ .flatMap(e -> e.getReviewers().stream())
+ .map(accountInfo -> Account.id(accountInfo._accountId))
+ .collect(toImmutableSet());
+ }
+ }
+
+ private static class TestReviewerDeletedListener implements ReviewerDeletedListener {
+ List<ReviewerDeletedListener.Event> receivedEvents = new ArrayList<>();
+
+ @Override
+ public void onReviewerDeleted(ReviewerDeletedListener.Event event) {
+ receivedEvents.add(event);
+ }
+
+ public ImmutableSet<Account.Id> getReviewerIds() {
+ return receivedEvents.stream()
+ .map(ReviewerDeletedListener.Event::getReviewer)
+ .map(accountInfo -> Account.id(accountInfo._accountId))
+ .collect(toImmutableSet());
+ }
+ }
}