Merge "Add an extension point to extend the change message on post review"

This commit is contained in:
Edwin Kempin
2020-12-10 11:11:18 +00:00
committed by Gerrit Code Review
6 changed files with 268 additions and 1 deletions

View File

@@ -1453,6 +1453,19 @@ this can be specified by setting `scope = CapabilityScope.CORE`:
[...]
----
[post_review_extensions]
== Post Review Extensions
By implementing the `com.google.gerrit.server.restapi.change.OnPostReview`
interface plugins can extend the change message that is being posted when the
[post review](rest-api-changes.html#set-review) REST endpoint is invoked.
This is useful if certain approvals have a special meaning (e.g. custom logic
that is implemented in Prolog submit rules, signal for triggering an action
like running CI etc.), as it allows the plugin to tell users about this meaning
in the change message. This makes the effect of a given approval more
transparent to the user.
[[ui_extension]]
== UI Extension

View File

@@ -43,6 +43,7 @@ import com.google.gerrit.server.git.validators.CommitValidationListener;
import com.google.gerrit.server.git.validators.OnSubmitValidationListener;
import com.google.gerrit.server.git.validators.RefOperationValidationListener;
import com.google.gerrit.server.logging.PerformanceLogger;
import com.google.gerrit.server.restapi.change.OnPostReview;
import com.google.gerrit.server.rules.SubmitRule;
import com.google.gerrit.server.validators.AccountActivationValidationListener;
import com.google.gerrit.server.validators.ProjectCreationValidationListener;
@@ -83,6 +84,7 @@ public class ExtensionRegistry {
private final DynamicMap<CapabilityDefinition> capabilityDefinitions;
private final DynamicMap<PluginProjectPermissionDefinition> pluginProjectPermissionDefinitions;
private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
private final DynamicSet<OnPostReview> onPostReviews;
@Inject
ExtensionRegistry(
@@ -113,7 +115,8 @@ public class ExtensionRegistry {
DynamicSet<WorkInProgressStateChangedListener> workInProgressStateChangedListeners,
DynamicMap<CapabilityDefinition> capabilityDefinitions,
DynamicMap<PluginProjectPermissionDefinition> pluginProjectPermissionDefinitions,
DynamicMap<ProjectConfigEntry> pluginConfigEntries) {
DynamicMap<ProjectConfigEntry> pluginConfigEntries,
DynamicSet<OnPostReview> onPostReviews) {
this.accountIndexedListeners = accountIndexedListeners;
this.changeIndexedListeners = changeIndexedListeners;
this.groupIndexedListeners = groupIndexedListeners;
@@ -142,6 +145,7 @@ public class ExtensionRegistry {
this.capabilityDefinitions = capabilityDefinitions;
this.pluginProjectPermissionDefinitions = pluginProjectPermissionDefinitions;
this.pluginConfigEntries = pluginConfigEntries;
this.onPostReviews = onPostReviews;
}
public Registration newRegistration() {
@@ -270,6 +274,10 @@ public class ExtensionRegistry {
return add(pluginConfigEntries, pluginConfigEntry, exportName);
}
public Registration add(OnPostReview onPostReview) {
return add(onPostReviews, onPostReview);
}
private <T> Registration add(DynamicSet<T> dynamicSet, T extension) {
return add(dynamicSet, extension, "gerrit");
}

View File

@@ -180,6 +180,7 @@ import com.google.gerrit.server.query.change.ChangeIsVisibleToPredicate;
import com.google.gerrit.server.query.change.ChangeQueryBuilder;
import com.google.gerrit.server.query.change.ConflictsCacheImpl;
import com.google.gerrit.server.quota.QuotaEnforcer;
import com.google.gerrit.server.restapi.change.OnPostReview;
import com.google.gerrit.server.restapi.change.SuggestReviewers;
import com.google.gerrit.server.restapi.group.GroupModule;
import com.google.gerrit.server.rules.DefaultSubmitRule;
@@ -413,6 +414,7 @@ public class GerritGlobalModule extends FactoryModule {
DynamicSet.setOf(binder(), ExceptionHook.class);
DynamicSet.bind(binder(), ExceptionHook.class).to(ExceptionHookImpl.class);
DynamicSet.setOf(binder(), MailSoyTemplateProvider.class);
DynamicSet.setOf(binder(), OnPostReview.class);
DynamicMap.mapOf(binder(), MailFilter.class);
bind(MailFilter.class).annotatedWith(Exports.named("ListMailFilter")).to(ListMailFilter.class);

View File

@@ -0,0 +1,47 @@
// Copyright (C) 2020 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.restapi.change;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.extensions.annotations.ExtensionPoint;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.notedb.ChangeNotes;
import java.util.Map;
import java.util.Optional;
/** Extension point that is invoked on post review. */
@ExtensionPoint
public interface OnPostReview {
/**
* Allows implementors to return a message that should be included into the change message that is
* posted on post review.
*
* @param user the user that posts the review
* @param changeNotes the change on which post review is performed
* @param patchSet the patch set on which post review is performed
* @param oldApprovals old approvals that changed as result of post review
* @param approvals all current approvals
* @return message that should be included into the change message that is posted on post review,
* {@link Optional#empty()} if the change message should not be extended
*/
default Optional<String> getChangeMessageAddOn(
IdentifiedUser user,
ChangeNotes changeNotes,
PatchSet patchSet,
Map<String, Short> oldApprovals,
Map<String, Short> approvals) {
return Optional.empty();
}
}

View File

@@ -30,6 +30,7 @@ import static java.util.stream.Collectors.toSet;
import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
import com.google.auto.value.AutoValue;
import com.google.common.base.Joiner;
import com.google.common.base.Strings;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.Lists;
@@ -177,6 +178,7 @@ public class PostReview implements RestModifyView<RevisionResource, ReviewInput>
private final ProjectCache projectCache;
private final PermissionBackend permissionBackend;
private final PluginSetContext<CommentValidator> commentValidators;
private final PluginSetContext<OnPostReview> onPostReviews;
private final ReplyAttentionSetUpdates replyAttentionSetUpdates;
private final boolean strictLabels;
@@ -202,6 +204,7 @@ public class PostReview implements RestModifyView<RevisionResource, ReviewInput>
ProjectCache projectCache,
PermissionBackend permissionBackend,
PluginSetContext<CommentValidator> commentValidators,
PluginSetContext<OnPostReview> onPostReviews,
ReplyAttentionSetUpdates replyAttentionSetUpdates) {
this.updateFactory = updateFactory;
this.changeResourceFactory = changeResourceFactory;
@@ -222,6 +225,7 @@ public class PostReview implements RestModifyView<RevisionResource, ReviewInput>
this.projectCache = projectCache;
this.permissionBackend = permissionBackend;
this.commentValidators = commentValidators;
this.onPostReviews = onPostReviews;
this.replyAttentionSetUpdates = replyAttentionSetUpdates;
this.strictLabels = gerritConfig.getBoolean("change", "strictLabels", false);
}
@@ -1413,6 +1417,23 @@ public class PostReview implements RestModifyView<RevisionResource, ReviewInput>
} else if (in.ready) {
buf.append("\n\n" + START_REVIEW_MESSAGE);
}
List<String> pluginMessages = new ArrayList<>();
onPostReviews.runEach(
onPostReview ->
onPostReview
.getChangeMessageAddOn(user, ctx.getNotes(), ps, oldApprovals, approvals)
.ifPresent(
pluginMessage ->
pluginMessages.add(
!pluginMessage.endsWith("\n")
? pluginMessage + "\n"
: pluginMessage)));
if (!pluginMessages.isEmpty()) {
buf.append("\n\n");
buf.append(Joiner.on("\n").join(pluginMessages));
}
if (buf.length() == 0) {
return false;
}

View File

@@ -29,9 +29,16 @@ import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Iterables;
import com.google.common.truth.Correspondence;
import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.ExtensionRegistry;
import com.google.gerrit.acceptance.ExtensionRegistry.Registration;
import com.google.gerrit.acceptance.PushOneCommit;
import com.google.gerrit.acceptance.TestAccount;
import com.google.gerrit.acceptance.config.GerritConfig;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.extensions.annotations.Exports;
import com.google.gerrit.extensions.api.changes.DraftInput;
import com.google.gerrit.extensions.api.changes.ReviewInput;
@@ -49,6 +56,9 @@ import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.extensions.validators.CommentForValidation;
import com.google.gerrit.extensions.validators.CommentValidationContext;
import com.google.gerrit.extensions.validators.CommentValidator;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.notedb.ChangeNotes;
import com.google.gerrit.server.restapi.change.OnPostReview;
import com.google.gerrit.server.restapi.change.PostReview;
import com.google.gerrit.server.update.CommentsRejectedException;
import com.google.gerrit.testing.TestCommentHelper;
@@ -58,6 +68,7 @@ import java.sql.Timestamp;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.junit.Before;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
@@ -69,6 +80,7 @@ public class PostReviewIT extends AbstractDaemonTest {
@Inject private CommentValidator mockCommentValidator;
@Inject private TestCommentHelper testCommentHelper;
@Inject private RequestScopeOperations requestScopeOperations;
@Inject private ExtensionRegistry extensionRegistry;
private static final String COMMENT_TEXT = "The comment text";
private static final CommentForValidation FILE_COMMENT_FOR_VALIDATION =
@@ -474,6 +486,124 @@ public class PostReviewIT extends AbstractDaemonTest {
assertThat(reviewer._accountId).isEqualTo(user.id().get());
}
@Test
public void extendChangeMessageFromPlugin() throws Exception {
PushOneCommit.Result r = createChange();
String testMessage = "hello from plugin";
TestOnPostReview testOnPostReview = new TestOnPostReview(testMessage);
try (Registration registration = extensionRegistry.newRegistration().add(testOnPostReview)) {
ReviewInput input = new ReviewInput().label("Code-Review", 1);
gApi.changes().id(r.getChangeId()).current().review(input);
Collection<ChangeMessageInfo> messages = gApi.changes().id(r.getChangeId()).get().messages;
assertThat(Iterables.getLast(messages).message)
.isEqualTo(String.format("Patch Set 1: Code-Review+1\n\n%s\n", testMessage));
}
}
@Test
public void extendChangeMessageFromMultiplePlugins() throws Exception {
PushOneCommit.Result r = createChange();
String testMessage1 = "hello from plugin 1";
String testMessage2 = "message from plugin 2";
TestOnPostReview testOnPostReview1 = new TestOnPostReview(testMessage1);
TestOnPostReview testOnPostReview2 = new TestOnPostReview(testMessage2);
try (Registration registration =
extensionRegistry.newRegistration().add(testOnPostReview1).add(testOnPostReview2)) {
ReviewInput input = new ReviewInput().label("Code-Review", 1);
gApi.changes().id(r.getChangeId()).current().review(input);
Collection<ChangeMessageInfo> messages = gApi.changes().id(r.getChangeId()).get().messages;
assertThat(Iterables.getLast(messages).message)
.isEqualTo(
String.format(
"Patch Set 1: Code-Review+1\n\n%s\n\n%s\n", testMessage1, testMessage2));
}
}
@Test
public void onPostReviewExtensionThatDoesntExtendTheChangeMessage() throws Exception {
PushOneCommit.Result r = createChange();
TestOnPostReview testOnPostReview = new TestOnPostReview(/* message= */ null);
try (Registration registration = extensionRegistry.newRegistration().add(testOnPostReview)) {
ReviewInput input = new ReviewInput().label("Code-Review", 1);
gApi.changes().id(r.getChangeId()).current().review(input);
Collection<ChangeMessageInfo> messages = gApi.changes().id(r.getChangeId()).get().messages;
assertThat(Iterables.getLast(messages).message).isEqualTo("Patch Set 1: Code-Review+1");
}
}
@Test
public void onPostReviewCallbackGetsCorrectChangeAndPatchSet() throws Exception {
PushOneCommit.Result r = createChange();
amendChange(r.getChangeId());
TestOnPostReview testOnPostReview = new TestOnPostReview(/* message= */ null);
try (Registration registration = extensionRegistry.newRegistration().add(testOnPostReview)) {
ReviewInput input = new ReviewInput().label("Code-Review", 1);
// Vote on current patch set.
gApi.changes().id(r.getChangeId()).current().review(input);
testOnPostReview.assertChangeAndPatchSet(r.getChange().getId(), 2);
// Vote on old patch set.
gApi.changes().id(r.getChangeId()).revision(1).review(input);
testOnPostReview.assertChangeAndPatchSet(r.getChange().getId(), 1);
}
}
@Test
public void onPostReviewCallbackGetsCorrectUser() throws Exception {
PushOneCommit.Result r = createChange();
TestOnPostReview testOnPostReview = new TestOnPostReview(/* message= */ null);
try (Registration registration = extensionRegistry.newRegistration().add(testOnPostReview)) {
ReviewInput input = new ReviewInput().label("Code-Review", 1);
// Vote from admin.
gApi.changes().id(r.getChangeId()).current().review(input);
testOnPostReview.assertUser(admin);
// Vote from user.
requestScopeOperations.setApiUser(user.id());
gApi.changes().id(r.getChangeId()).current().review(input);
testOnPostReview.assertUser(user);
}
}
@Test
public void onPostReviewCallbackGetsCorrectApprovals() throws Exception {
PushOneCommit.Result r = createChange();
TestOnPostReview testOnPostReview = new TestOnPostReview(/* message= */ null);
try (Registration registration = extensionRegistry.newRegistration().add(testOnPostReview)) {
// Add a new vote.
ReviewInput input = new ReviewInput().label("Code-Review", 1);
gApi.changes().id(r.getChangeId()).current().review(input);
testOnPostReview.assertApproval(
"Code-Review", /* expectedOldValue= */ 0, /* expectedNewValue= */ 1);
// Update an existing vote.
input = new ReviewInput().label("Code-Review", 2);
gApi.changes().id(r.getChangeId()).current().review(input);
testOnPostReview.assertApproval(
"Code-Review", /* expectedOldValue= */ 1, /* expectedNewValue= */ 2);
// Post without changing the vote.
input = new ReviewInput().label("Code-Review", 2);
gApi.changes().id(r.getChangeId()).current().review(input);
testOnPostReview.assertApproval(
"Code-Review", /* expectedOldValue= */ null, /* expectedNewValue= */ 2);
// Delete the vote.
input = new ReviewInput().label("Code-Review", 0);
gApi.changes().id(r.getChangeId()).current().review(input);
testOnPostReview.assertApproval(
"Code-Review", /* expectedOldValue= */ 2, /* expectedNewValue= */ 0);
}
}
private List<RobotCommentInfo> getRobotComments(String changeId) throws RestApiException {
return gApi.changes().id(changeId).robotComments().values().stream()
.flatMap(Collection::stream)
@@ -495,4 +625,50 @@ public class PostReviewIT extends AbstractDaemonTest {
.comparingElementsUsing(COMMENT_CORRESPONDENCE)
.containsExactly(commentsForValidation);
}
private static class TestOnPostReview implements OnPostReview {
private final Optional<String> message;
private Change.Id changeId;
private PatchSet.Id patchSetId;
private Account.Id accountId;
private Map<String, Short> oldApprovals;
private Map<String, Short> approvals;
TestOnPostReview(@Nullable String message) {
this.message = Optional.ofNullable(message);
}
@Override
public Optional<String> getChangeMessageAddOn(
IdentifiedUser user,
ChangeNotes changeNotes,
PatchSet patchSet,
Map<String, Short> oldApprovals,
Map<String, Short> approvals) {
this.changeId = changeNotes.getChangeId();
this.patchSetId = patchSet.id();
this.accountId = user.getAccountId();
this.oldApprovals = oldApprovals;
this.approvals = approvals;
return message;
}
public void assertChangeAndPatchSet(Change.Id expectedChangeId, int expectedPatchSetNum) {
assertThat(changeId).isEqualTo(expectedChangeId);
assertThat(patchSetId.get()).isEqualTo(expectedPatchSetNum);
}
public void assertUser(TestAccount expectedUser) {
assertThat(accountId).isEqualTo(expectedUser.id());
}
public void assertApproval(
String labelName, @Nullable Integer expectedOldValue, int expectedNewValue) {
assertThat(oldApprovals)
.containsExactly(
labelName, expectedOldValue != null ? expectedOldValue.shortValue() : null);
assertThat(approvals).containsExactly(labelName, (short) expectedNewValue);
}
}
}