Add an option to keep votes when moving the change to another branch.

The intention of this change is to support master -> main migration.

Today, only the veto votes that are blocking the change from submission
are moved to the destination branch. Adding an option allows admin to
decide what to do with the votes when moving open changes, using a
simple script.

Added 'keepAllVotes' option so it is possible to move all votes. The
option is only allowed to be used by admins at their own risk, because
it affects the submission behavior of the change depending on the label
access configuration and submission rules.

We could squash the user votes to the allowed permission range:
* In that case the original votes would be lost when moving back
to the original branch.
* It is expensive to call permission backend
* The votes are often granted in the context of a specific branch

Other options and considerations in the original discussion:
Iae0443b16151

Change-Id: Ia197a779f93e928604a0d0ea3360825dcfbeb6d9
This commit is contained in:
Marija Savtchouk
2020-12-03 12:58:04 +00:00
parent de50d7ea24
commit cee6a8fed1
4 changed files with 275 additions and 3 deletions

View File

@@ -1388,6 +1388,8 @@ Move a change.
The destination branch must be provided in the request body inside a
link:#move-input[MoveInput] entity.
Only veto votes that are blocking the change from submission are moved to
the destination branch by default.
.Request
----
@@ -7354,6 +7356,11 @@ The `MoveInput` entity contains information for moving a change to a new branch.
|`destination_branch`||Destination branch
|`message` |optional|
A message to be posted in this change's comments
|`keep_all_labels` |optional, defaults to false|
By default, only veto votes that are blocking the change from submission are moved to
the destination branch. Using this option is only allowed for administrators,
because it can affect the submission behaviour of the change (depending on the label access
configuration and submissions rules).
|===========================
[[notify-info]]

View File

@@ -17,4 +17,14 @@ package com.google.gerrit.extensions.api.changes;
public class MoveInput {
public String message;
public String destinationBranch;
/**
* Whether or not to keep all votes in the destination branch. Keeping the votes can be confusing
* in the context of the destination branch, see
* https://gerrit-review.googlesource.com/c/gerrit/+/129171. That is why only the users with
* {@link com.google.gerrit.server.permissions.GlobalPermission#ADMINISTRATE_SERVER} permissions
* can use this option.
*
* <p>By default, only the veto votes that are blocking the change from submission are moved.
*/
public boolean keepAllVotes;
}

View File

@@ -52,6 +52,7 @@ import com.google.gerrit.server.change.ChangeResource;
import com.google.gerrit.server.config.GerritServerConfig;
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.notedb.ChangeUpdate;
import com.google.gerrit.server.permissions.GlobalPermission;
import com.google.gerrit.server.permissions.PermissionBackend;
import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.gerrit.server.project.ProjectCache;
@@ -140,6 +141,18 @@ public class Move implements RestModifyView<ChangeResource, MoveInput>, UiAction
// Not allowed to move if the current patch set is locked.
psUtil.checkPatchSetNotLocked(rsrc.getNotes());
// Keeping all votes can be confusing in the context of the destination branch, see the
// discussion in
// https://gerrit-review.googlesource.com/c/gerrit/+/129171
// Only administrators are allowed to keep all labels at their own risk.
try {
if (input.keepAllVotes) {
permissionBackend.user(caller).check(GlobalPermission.ADMINISTRATE_SERVER);
}
} catch (AuthException denied) {
throw new AuthException("move is not permitted with keepAllVotes option", denied);
}
// Move requires abandoning this change, and creating a new change.
try {
rsrc.permissions().check(ABANDON);
@@ -226,7 +239,9 @@ public class Move implements RestModifyView<ChangeResource, MoveInput>, UiAction
update.setBranch(newDestKey.branch());
change.setDest(newDestKey);
updateApprovals(ctx, update, psId, projectKey);
if (!input.keepAllVotes) {
updateApprovals(ctx, update, psId, projectKey);
}
StringBuilder msgBuf = new StringBuilder();
msgBuf.append("Change destination moved from ");

View File

@@ -16,15 +16,19 @@ package com.google.gerrit.acceptance.rest.change;
import static com.google.common.truth.Truth.assertThat;
import static com.google.gerrit.acceptance.GitUtil.pushHead;
import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allowLabel;
import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.block;
import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
import static com.google.gerrit.server.project.testing.TestLabels.value;
import static com.google.gerrit.testing.GerritJUnit.assertThrows;
import com.google.common.collect.ImmutableList;
import com.google.gerrit.acceptance.AbstractDaemonTest;
import com.google.gerrit.acceptance.GitUtil;
import com.google.gerrit.acceptance.NoHttpd;
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.project.ProjectOperations;
import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
@@ -190,7 +194,7 @@ public class MoveChangeIT extends AbstractDaemonTest {
.update();
AuthException thrown =
assertThrows(AuthException.class, () -> move(r.getChangeId(), newBranch.branch()));
assertThat(thrown).hasMessageThat().contains("move not permitted");
assertThat(thrown).hasMessageThat().isEqualTo("move not permitted");
}
@Test
@@ -210,7 +214,7 @@ public class MoveChangeIT extends AbstractDaemonTest {
requestScopeOperations.setApiUser(user.id());
AuthException thrown =
assertThrows(AuthException.class, () -> move(r.getChangeId(), newBranch.branch()));
assertThat(thrown).hasMessageThat().contains("move not permitted");
assertThat(thrown).hasMessageThat().isEqualTo("move not permitted");
}
@Test
@@ -268,6 +272,224 @@ public class MoveChangeIT extends AbstractDaemonTest {
String.format("The current patch set of change %s is locked", r.getChange().getId()));
}
@Test
public void moveChangeKeepAllVotesOnlyAllowedForAdmins() throws Exception {
// Keep all votes options is only permitted for admins.
BranchNameKey destinationBranch = BranchNameKey.create(project, "dest");
createBranch(destinationBranch);
BranchNameKey sourceBranch = BranchNameKey.create(project, "source");
createBranch(sourceBranch);
String changeId = createChangeInBranch(sourceBranch.branch()).getChangeId();
// Grant change permissions to the registered users.
projectOperations
.project(project)
.forUpdate()
.add(allow(Permission.PUSH).ref(destinationBranch.branch()).group(REGISTERED_USERS))
.update();
projectOperations
.project(project)
.forUpdate()
.add(allow(Permission.ABANDON).ref(sourceBranch.branch()).group(REGISTERED_USERS))
.update();
requestScopeOperations.setApiUser(user.id());
AuthException thrown =
assertThrows(
AuthException.class, () -> move(changeId, destinationBranch.shortName(), true));
assertThat(thrown).hasMessageThat().isEqualTo("move is not permitted with keepAllVotes option");
requestScopeOperations.setApiUser(admin.id());
move(changeId, destinationBranch.branch(), true);
assertThat(info(changeId).branch).isEqualTo(destinationBranch.shortName());
}
@Test
public void moveChangeKeepAllVotesNoLabelInDestination() throws Exception {
BranchNameKey destinationBranch = BranchNameKey.create(project, "dest");
createBranch(destinationBranch);
BranchNameKey sourceBranch = BranchNameKey.create(project, "source");
createBranch(sourceBranch);
String testLabelA = "Label-A";
// The label has the range [-1; 1]
configLabel(testLabelA, LabelFunction.NO_BLOCK, ImmutableList.of(sourceBranch.branch()));
// Registered users have permissions for the entire range [-1; 1] on all branches.
projectOperations
.project(project)
.forUpdate()
.add(allowLabel(testLabelA).ref("refs/heads/*").group(REGISTERED_USERS).range(-1, +1))
.update();
String changeId = createChangeInBranch(sourceBranch.branch()).getChangeId();
requestScopeOperations.setApiUser(user.id());
ReviewInput userReviewInput = new ReviewInput();
userReviewInput.label(testLabelA, 1);
gApi.changes().id(changeId).current().review(userReviewInput);
assertLabelVote(user, changeId, testLabelA, (short) 1);
requestScopeOperations.setApiUser(admin.id());
assertThat(atrScope.get().getUser().getAccountId()).isEqualTo(admin.id());
// Move the change to the destination branch.
assertThat(info(changeId).branch).isEqualTo(sourceBranch.shortName());
move(changeId, destinationBranch.shortName(), true);
assertThat(info(changeId).branch).isEqualTo(destinationBranch.shortName());
// Label is missing in the destination branch.
assertThat(gApi.changes().id(changeId).current().reviewer(user.email()).votes()).isEmpty();
// Move the change back to the source, the label is kept.
move(changeId, sourceBranch.shortName(), true);
assertThat(info(changeId).branch).isEqualTo(sourceBranch.shortName());
assertLabelVote(user, changeId, testLabelA, (short) 1);
}
@Test
public void moveChangeKeepAllVotesOutOfUserPermissionRange() throws Exception {
BranchNameKey destinationBranch = BranchNameKey.create(project, "dest");
createBranch(destinationBranch);
BranchNameKey sourceBranch = BranchNameKey.create(project, "source");
createBranch(sourceBranch);
String testLabelA = "Label-A";
// The label has the range [-2; 2]
configLabel(
project,
testLabelA,
LabelFunction.NO_BLOCK,
value(2, "Passes"),
value(1, "Mostly ok"),
value(0, "No score"),
value(-1, "Needs work"),
value(-2, "Failed"));
// Registered users have [-2; 2] permissions on the source.
projectOperations
.project(project)
.forUpdate()
.add(
allowLabel(testLabelA).ref(sourceBranch.branch()).group(REGISTERED_USERS).range(-2, +2))
.update();
// Registered users have [-1; 1] permissions on the destination.
projectOperations
.project(project)
.forUpdate()
.add(
allowLabel(testLabelA)
.ref(destinationBranch.branch())
.group(REGISTERED_USERS)
.range(-1, +1))
.update();
String changeId = createChangeInBranch(sourceBranch.branch()).getChangeId();
requestScopeOperations.setApiUser(user.id());
// Vote within the range of the source branch.
ReviewInput userReviewInput = new ReviewInput();
userReviewInput.label(testLabelA, 2);
gApi.changes().id(changeId).current().review(userReviewInput);
assertLabelVote(user, changeId, testLabelA, (short) 2);
requestScopeOperations.setApiUser(admin.id());
assertThat(atrScope.get().getUser().getAccountId()).isEqualTo(admin.id());
// Move the change to the destination branch.
assertThat(info(changeId).branch).isEqualTo(sourceBranch.shortName());
move(changeId, destinationBranch.branch(), true);
// User does not have label permissions for the same vote on the destination branch.
requestScopeOperations.setApiUser(user.id());
AuthException thrown =
assertThrows(
AuthException.class,
() -> gApi.changes().id(changeId).current().review(userReviewInput));
assertThat(thrown)
.hasMessageThat()
.isEqualTo(String.format("Applying label \"%s\": 2 is restricted", testLabelA));
// Label is kept even though the user's permission range is different from the source.
// Since we do not squash users votes based on the destination branch access label
// configuration, this is working as intended.
// It's the same behavior as when a project owner reduces user's permission range on label.
// Administrators should take this into account.
assertThat(info(changeId).branch).isEqualTo(destinationBranch.shortName());
assertLabelVote(user, changeId, testLabelA, (short) 2);
requestScopeOperations.setApiUser(admin.id());
// Move the change back to the source, the label is kept.
move(changeId, sourceBranch.shortName(), true);
assertThat(info(changeId).branch).isEqualTo(sourceBranch.shortName());
assertLabelVote(user, changeId, testLabelA, (short) 2);
}
@Test
public void moveKeepAllVotesCanMoveAllInRange() throws Exception {
BranchNameKey destinationBranch = BranchNameKey.create(project, "dest");
createBranch(destinationBranch);
BranchNameKey sourceBranch = BranchNameKey.create(project, "source");
createBranch(sourceBranch);
// The non-block label has the range [-2; 2]
String testLabelA = "Label-A";
configLabel(
project,
testLabelA,
LabelFunction.NO_BLOCK,
value(2, "Passes"),
value(1, "Mostly ok"),
value(0, "No score"),
value(-1, "Needs work"),
value(-2, "Failed"));
// Registered users have [-2; 2] permissions on all branches.
projectOperations
.project(project)
.forUpdate()
.add(allowLabel(testLabelA).ref("refs/heads/*").group(REGISTERED_USERS).range(-2, +2))
.update();
String changeId = createChangeInBranch(sourceBranch.branch()).getChangeId();
for (int vote = -2; vote <= 2; vote++) {
TestAccount testUser = accountCreator.create("TestUser" + vote);
requestScopeOperations.setApiUser(testUser.id());
ReviewInput userReviewInput = new ReviewInput();
userReviewInput.label(testLabelA, vote);
gApi.changes().id(changeId).current().review(userReviewInput);
}
assertThat(
gApi.changes().id(changeId).current().votes().get(testLabelA).stream()
.map(approvalInfo -> approvalInfo.value)
.collect(ImmutableList.toImmutableList()))
.containsExactly(-2, -1, 1, 2);
requestScopeOperations.setApiUser(admin.id());
// Move the change to the destination branch.
assertThat(info(changeId).branch).isEqualTo(sourceBranch.shortName());
move(changeId, destinationBranch.shortName(), true);
assertThat(info(changeId).branch).isEqualTo(destinationBranch.shortName());
// All votes are kept
assertThat(
gApi.changes().id(changeId).current().votes().get(testLabelA).stream()
.map(approvalInfo -> approvalInfo.value)
.collect(ImmutableList.toImmutableList()))
.containsExactly(-2, -1, 1, 2);
// Move the change back to the source, the label is kept.
move(changeId, sourceBranch.shortName(), true);
assertThat(info(changeId).branch).isEqualTo(sourceBranch.shortName());
assertThat(
gApi.changes().id(changeId).current().votes().get(testLabelA).stream()
.map(approvalInfo -> approvalInfo.value)
.collect(ImmutableList.toImmutableList()))
.containsExactly(-2, -1, 1, 2);
}
@Test
public void moveChangeOnlyKeepVetoVotes() throws Exception {
// A vote for a label will be kept after moving if the label's function is *WithBlock and the
@@ -394,10 +616,28 @@ public class MoveChangeIT extends AbstractDaemonTest {
gApi.changes().id(changeId).move(in);
}
private void move(String changeId, String destination, boolean keepAllVotes)
throws RestApiException {
MoveInput in = new MoveInput();
in.destinationBranch = destination;
in.keepAllVotes = keepAllVotes;
gApi.changes().id(changeId).move(in);
}
private PushOneCommit.Result createChange(String branch, String changeId) throws Exception {
PushOneCommit push = pushFactory.create(admin.newIdent(), testRepo, changeId);
PushOneCommit.Result result = push.to("refs/for/" + branch);
result.assertOkStatus();
return result;
}
private PushOneCommit.Result createChangeInBranch(String branch) throws Exception {
return createChange("refs/for/" + branch);
}
private void assertLabelVote(TestAccount user, String changeId, String label, short vote)
throws Exception {
assertThat(gApi.changes().id(changeId).current().reviewer(user.email()).votes())
.containsEntry(label, vote);
}
}