Add a way to change the destination of open changes

When the development is moved from one branch to next in a git project
and we want to get all the open changes from old branch to the new branch,
we have to create a new copy of those changes for the new branch.

Since these changes are not required on the old branch, moving would be
a better fit than other propagation methods (cherrypicking, manual upload).

This change provides a REST API '/changes/{change-id}/move' to
move an open change to a different branch.

Change-Id: I44c68be1d08e2e1f4eab4e4f1724047f20072ac3
This commit is contained in:
Raviteja Sunkara
2015-11-03 13:24:50 +05:30
committed by Nasser Grainawi
parent eedd8a3b9b
commit 791f339090
12 changed files with 660 additions and 8 deletions

View File

@@ -983,6 +983,84 @@ body.
The change could not be rebased due to a path conflict during merge.
----
[[move-change]]
=== Move Change
--
'POST /changes/link:#change-id[\{change-id\}]/move'
--
Move a change.
The destination branch must be provided in the request body inside a
link:#move-input[MoveInput] entity.
.Request
----
POST /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/move HTTP/1.0
Content-Type: application/json; charset=UTF-8
{
"destination_branch" : "release-branch"
}
----
As response a link:#change-info[ChangeInfo] entity is returned that
describes the moved change.
.Response
----
HTTP/1.1 200 OK
Content-Disposition: attachment
Content-Type: application/json; charset=UTF-8
)]}'
{
"id": "myProject~release-branch~I8473b95934b5732ac55d26311a706c9c2bde9940",
"project": "myProject",
"branch": "release-branch",
"change_id": "I8473b95934b5732ac55d26311a706c9c2bde9940",
"subject": "Implementing Feature X",
"status": "NEW",
"created": "2013-02-01 09:59:32.126000000",
"updated": "2013-02-21 11:16:36.775000000",
"mergeable": true,
"insertions": 2,
"deletions": 13,
"_number": 3965,
"owner": {
"name": "John Doe"
}
}
----
If the change cannot be moved because the change state doesn't
allow moving the change, the response is "`409 Conflict`" and
the error message is contained in the response body.
.Response
----
HTTP/1.1 409 Conflict
Content-Disposition: attachment
Content-Type: text/plain; charset=UTF-8
change is merged
----
If the change cannot be moved because the user doesn't have
abandon permission on the change or upload permission on the destination,
the response is "`409 Conflict`" and the error message is contained in the
response body.
.Response
----
HTTP/1.1 409 Conflict
Content-Disposition: attachment
Content-Type: text/plain; charset=UTF-8
move not permitted
----
[[revert-change]]
=== Revert Change
--
@@ -4495,6 +4573,18 @@ Submit type used for this change, can be `MERGE_IF_NECESSARY`,
A list of other branch names where this change could merge cleanly
|============================
[[move-input]]
=== MoveInput
The `MoveInput` entity contains information for moving a change to a new branch.
[options="header",cols="1,^1,5"]
|===========================
|Field Name ||Description
|`destination`||Destination branch
|`message` |optional|
A message to be posted in this change's comments
|===========================
[[problem-info]]
=== ProblemInfo
The `ProblemInfo` entity contains a description of a potential consistency problem

View File

@@ -31,6 +31,8 @@ import com.google.gerrit.common.data.PermissionRule;
import com.google.gerrit.extensions.api.GerritApi;
import com.google.gerrit.extensions.api.changes.ReviewInput;
import com.google.gerrit.extensions.api.changes.RevisionApi;
import com.google.gerrit.extensions.api.projects.BranchApi;
import com.google.gerrit.extensions.api.projects.BranchInput;
import com.google.gerrit.extensions.api.projects.ProjectInput;
import com.google.gerrit.extensions.client.InheritableBoolean;
import com.google.gerrit.extensions.client.ListChangesOption;
@@ -40,6 +42,7 @@ import com.google.gerrit.extensions.common.EditInfo;
import com.google.gerrit.extensions.restapi.IdString;
import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.reviewdb.client.AccountGroup;
import com.google.gerrit.reviewdb.client.Branch;
import com.google.gerrit.reviewdb.client.PatchSet;
import com.google.gerrit.reviewdb.client.Project;
import com.google.gerrit.reviewdb.server.ReviewDb;
@@ -459,6 +462,13 @@ public abstract class AbstractDaemonTest {
return pushTo("refs/drafts/master");
}
protected BranchApi createBranch(Branch.NameKey branch) throws Exception {
return gApi.projects()
.name(branch.getParentKey().get())
.branch(branch.get())
.create(new BranchInput());
}
private static final List<Character> RANDOM =
Chars.asList(new char[]{'a','b','c','d','e','f','g','h'});
protected PushOneCommit.Result amendChange(String changeId)
@@ -475,6 +485,11 @@ public abstract class AbstractDaemonTest {
return push.to(ref);
}
protected void merge(PushOneCommit.Result r) throws Exception {
revision(r).review(ReviewInput.approve());
revision(r).submit();
}
protected ChangeInfo info(String id)
throws RestApiException {
return gApi.changes().id(id).info();

View File

@@ -73,6 +73,12 @@ public class PushOneCommit {
PersonIdent i,
TestRepository<?> testRepo);
PushOneCommit create(
ReviewDb db,
PersonIdent i,
TestRepository<?> testRepo,
@Assisted("changeId") String changeId);
PushOneCommit create(
ReviewDb db,
PersonIdent i,
@@ -136,6 +142,18 @@ public class PushOneCommit {
db, i, testRepo, SUBJECT, FILE_NAME, FILE_CONTENT);
}
@AssistedInject
PushOneCommit(ChangeNotes.Factory notesFactory,
ApprovalsUtil approvalsUtil,
Provider<InternalChangeQuery> queryProvider,
@Assisted ReviewDb db,
@Assisted PersonIdent i,
@Assisted TestRepository<?> testRepo,
@Assisted("changeId") String changeId) throws Exception {
this(notesFactory, approvalsUtil, queryProvider,
db, i, testRepo, SUBJECT, FILE_NAME, FILE_CONTENT, changeId);
}
@AssistedInject
PushOneCommit(ChangeNotes.Factory notesFactory,
ApprovalsUtil approvalsUtil,

View File

@@ -716,11 +716,6 @@ public class RevisionIT extends AbstractDaemonTest {
oldETag = checkETag(getRevisionActions, r2, oldETag);
}
private void merge(PushOneCommit.Result r) throws Exception {
revision(r).review(ReviewInput.approve());
revision(r).submit();
}
private PushOneCommit.Result updateChange(PushOneCommit.Result r,
String content) throws Exception {
PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo,

View File

@@ -0,0 +1,276 @@
// Copyright (C) 2015 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.acceptance.rest.change;
import static com.google.common.truth.Truth.assertThat;
import static com.google.gerrit.acceptance.GitUtil.pushHead;
import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
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.common.data.LabelType;
import com.google.gerrit.common.data.Permission;
import com.google.gerrit.extensions.api.changes.MoveInput;
import com.google.gerrit.extensions.api.changes.ReviewInput;
import com.google.gerrit.extensions.api.projects.BranchInput;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.reviewdb.client.AccountGroup;
import com.google.gerrit.reviewdb.client.Branch;
import com.google.gerrit.server.git.MetaDataUpdate;
import com.google.gerrit.server.git.ProjectConfig;
import com.google.gerrit.server.group.SystemGroupBackend;
import com.google.gerrit.server.project.Util;
import org.eclipse.jgit.junit.TestRepository;
import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.revwalk.RevCommit;
import org.junit.Test;
@NoHttpd
public class MoveChangeIT extends AbstractDaemonTest {
@Test
public void moveChange_shortRef() throws Exception {
// Move change to a different branch using short ref name
PushOneCommit.Result r = createChange();
Branch.NameKey newBranch =
new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
createBranch(newBranch);
move(r.getChangeId(), newBranch.getShortName());
assertThat(r.getChange().change().getDest()).isEqualTo(newBranch);
}
@Test
public void moveChange_fullRef() throws Exception {
// Move change to a different branch using full ref name
PushOneCommit.Result r = createChange();
Branch.NameKey newBranch =
new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
createBranch(newBranch);
move(r.getChangeId(), newBranch.get());
assertThat(r.getChange().change().getDest()).isEqualTo(newBranch);
}
@Test
public void moveChangeWithMessage() throws Exception {
// Provide a message using --message flag
PushOneCommit.Result r = createChange();
Branch.NameKey newBranch =
new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
createBranch(newBranch);
String moveMessage = "Moving for the move test";
move(r.getChangeId(), newBranch.get(), moveMessage);
assertThat(r.getChange().change().getDest()).isEqualTo(newBranch);
StringBuilder expectedMessage = new StringBuilder();
expectedMessage.append("Change destination moved from master to moveTest");
expectedMessage.append("\n\n");
expectedMessage.append(moveMessage);
assertThat(r.getChange().messages().get(1).getMessage())
.isEqualTo(expectedMessage.toString());
}
@Test
public void moveChangeToSameRefAsCurrent() throws Exception {
// Move change to the branch same as change's destination
PushOneCommit.Result r = createChange();
exception.expect(ResourceConflictException.class);
exception.expectMessage("Change is already destined for the specified branch");
move(r.getChangeId(), r.getChange().change().getDest().get());
}
@Test
public void moveChange_sameChangeId() throws Exception {
// Move change to a branch with existing change with same change ID
PushOneCommit.Result r = createChange();
Branch.NameKey newBranch =
new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
createBranch(newBranch);
int changeNum = r.getChange().change().getChangeId();
createChange(newBranch.get(), r.getChangeId());
exception.expect(ResourceConflictException.class);
exception.expectMessage("Destination " + newBranch.getShortName()
+ " has a different change with same change key " + r.getChangeId());
move(changeNum, newBranch.get());
}
@Test
public void moveChangeToNonExistentRef() throws Exception {
// Move change to a non-existing branch
PushOneCommit.Result r = createChange();
Branch.NameKey newBranch = new Branch.NameKey(
r.getChange().change().getProject(), "does_not_exist");
exception.expect(ResourceConflictException.class);
exception.expectMessage("Destination " + newBranch.get()
+ " not found in the project");
move(r.getChangeId(), newBranch.get());
}
@Test
public void moveClosedChange() throws Exception {
// Move a change which is not open
PushOneCommit.Result r = createChange();
Branch.NameKey newBranch =
new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
createBranch(newBranch);
merge(r);
exception.expect(ResourceConflictException.class);
exception.expectMessage("Change is merged");
move(r.getChangeId(), newBranch.get());
}
@Test
public void moveMergeCommitChange() throws Exception {
// Move a change which has a merge commit as the current PS
// Create a merge commit and push for review
PushOneCommit.Result r1 = createChange();
PushOneCommit.Result r2 = createChange();
TestRepository<?>.CommitBuilder commitBuilder =
testRepo.branch("HEAD").commit().insertChangeId();
commitBuilder
.parent(r1.getCommit())
.parent(r2.getCommit())
.message("Move change Merge Commit")
.author(admin.getIdent())
.committer(new PersonIdent(admin.getIdent(), testRepo.getDate()));
RevCommit c = commitBuilder.create();
pushHead(testRepo, "refs/for/master", false, false);
// Try to move the merge commit to another branch
Branch.NameKey newBranch =
new Branch.NameKey(r1.getChange().change().getProject(), "moveTest");
createBranch(newBranch);
exception.expect(ResourceConflictException.class);
exception.expectMessage("Merge commit cannot be moved");
move(GitUtil.getChangeId(testRepo, c).get(), newBranch.get());
}
@Test
public void moveChangeToBranch_WithoutUploadPerms() throws Exception {
// Move change to a destination where user doesn't have upload permissions
PushOneCommit.Result r = createChange();
Branch.NameKey newBranch =
new Branch.NameKey(r.getChange().change().getProject(), "blocked_branch");
createBranch(newBranch);
block(Permission.PUSH,
SystemGroupBackend.getGroup(REGISTERED_USERS).getUUID(),
"refs/for/" + newBranch.get());
exception.expect(AuthException.class);
exception.expectMessage("Move not permitted");
move(r.getChangeId(), newBranch.get());
}
@Test
public void moveChangeFromBranch_WithoutAbandonPerms() throws Exception {
// Move change for which user does not have abandon permissions
PushOneCommit.Result r = createChange();
Branch.NameKey newBranch =
new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
createBranch(newBranch);
block(Permission.ABANDON,
SystemGroupBackend.getGroup(REGISTERED_USERS).getUUID(),
r.getChange().change().getDest().get());
setApiUser(user);
exception.expect(AuthException.class);
exception.expectMessage("Move not permitted");
move(r.getChangeId(), newBranch.get());
}
@Test
public void moveChangeToBranchThatContainsCurrentCommit() throws Exception {
// Move change to a branch for which current PS revision is reachable from
// tip
// Create a change
PushOneCommit.Result r = createChange();
int changeNum = r.getChange().change().getChangeId();
// Create a branch with that same commit
Branch.NameKey newBranch =
new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
BranchInput bi = new BranchInput();
bi.revision = r.getCommit().name();
gApi.projects()
.name(newBranch.getParentKey().get())
.branch(newBranch.get())
.create(bi);
// Try to move the change to the branch with the same commit
exception.expect(ResourceConflictException.class);
exception
.expectMessage("Current patchset revision is reachable from tip of "
+ newBranch.get());
move(changeNum, newBranch.get());
}
@Test
public void moveChange_WithCurrentPatchSetLocked() throws Exception {
// Move change that is locked
PushOneCommit.Result r = createChange();
Branch.NameKey newBranch =
new Branch.NameKey(r.getChange().change().getProject(), "moveTest");
createBranch(newBranch);
ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
LabelType patchSetLock = Util.patchSetLock();
cfg.getLabelSections().put(patchSetLock.getName(), patchSetLock);
AccountGroup.UUID registeredUsers =
SystemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
Util.allow(cfg, Permission.forLabel(patchSetLock.getName()), 0, 1, registeredUsers,
"refs/heads/*");
saveProjectConfig(cfg);
grant(Permission.LABEL + "Patch-Set-Lock", project, "refs/heads/*");
revision(r).review(new ReviewInput().label("Patch-Set-Lock", 1));
exception.expect(AuthException.class);
exception.expectMessage("Move not permitted");
move(r.getChangeId(), newBranch.get());
}
private void saveProjectConfig(ProjectConfig cfg) throws Exception {
try (MetaDataUpdate md = metaDataUpdateFactory.create(project)) {
cfg.commit(md);
}
}
private void move(int changeNum, String destination)
throws RestApiException {
gApi.changes().id(changeNum).move(destination);
}
private void move(String changeId, String destination)
throws RestApiException {
gApi.changes().id(changeId).move(destination);
}
private void move(String changeId, String destination, String message)
throws RestApiException {
MoveInput in = new MoveInput();
in.destination_branch = destination;
in.message = message;
gApi.changes().id(changeId).move(in);
}
private PushOneCommit.Result createChange(String branch, String changeId)
throws Exception {
PushOneCommit push =
pushFactory.create(db, admin.getIdent(), testRepo, changeId);
PushOneCommit.Result result = push.to("refs/for/" + branch);
result.assertOkStatus();
return result;
}
}

View File

@@ -422,9 +422,9 @@ public class LabelTypeIT extends AbstractDaemonTest {
}
}
private void merge(PushOneCommit.Result r) throws Exception {
revision(r).review(ReviewInput.approve());
revision(r).submit();
@Override
protected void merge(PushOneCommit.Result r) throws Exception {
super.merge(r);
try (Repository repo = repoManager.openRepository(project)) {
assertThat(repo.exactRef("refs/heads/master").getObjectId()).isEqualTo(
r.getCommit());

View File

@@ -77,6 +77,9 @@ public interface ChangeApi {
void restore() throws RestApiException;
void restore(RestoreInput in) throws RestApiException;
void move(String destination) throws RestApiException;
void move(MoveInput in) throws RestApiException;
/**
* Create a new change that reverts this change.
*
@@ -229,6 +232,16 @@ public interface ChangeApi {
throw new NotImplementedException();
}
@Override
public void move(String destination) throws RestApiException {
throw new NotImplementedException();
}
@Override
public void move(MoveInput in) throws RestApiException {
throw new NotImplementedException();
}
@Override
public ChangeApi revert() throws RestApiException {
throw new NotImplementedException();

View File

@@ -0,0 +1,20 @@
// Copyright (C) 2015 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.extensions.api.changes;
public class MoveInput {
public String message;
public String destination_branch;
}

View File

@@ -20,6 +20,7 @@ import com.google.gerrit.extensions.api.changes.ChangeApi;
import com.google.gerrit.extensions.api.changes.Changes;
import com.google.gerrit.extensions.api.changes.FixInput;
import com.google.gerrit.extensions.api.changes.HashtagsInput;
import com.google.gerrit.extensions.api.changes.MoveInput;
import com.google.gerrit.extensions.api.changes.RestoreInput;
import com.google.gerrit.extensions.api.changes.RevertInput;
import com.google.gerrit.extensions.api.changes.ReviewerApi;
@@ -43,6 +44,7 @@ import com.google.gerrit.server.change.GetHashtags;
import com.google.gerrit.server.change.GetTopic;
import com.google.gerrit.server.change.ListChangeComments;
import com.google.gerrit.server.change.ListChangeDrafts;
import com.google.gerrit.server.change.Move;
import com.google.gerrit.server.change.PostHashtags;
import com.google.gerrit.server.change.PostReviewers;
import com.google.gerrit.server.change.PublishDraftPatchSet;
@@ -96,6 +98,7 @@ class ChangeApiImpl implements ChangeApi {
private final ListChangeDrafts listDrafts;
private final Check check;
private final ChangeEdits.Detail editDetail;
private final Move move;
@Inject
ChangeApiImpl(Provider<CurrentUser> user,
@@ -121,6 +124,7 @@ class ChangeApiImpl implements ChangeApi {
ListChangeDrafts listDrafts,
Check check,
ChangeEdits.Detail editDetail,
Move move,
@Assisted ChangeResource change) {
this.user = user;
this.changeApi = changeApi;
@@ -145,6 +149,7 @@ class ChangeApiImpl implements ChangeApi {
this.listDrafts = listDrafts;
this.check = check;
this.editDetail = editDetail;
this.move = move;
this.change = change;
}
@@ -211,6 +216,22 @@ class ChangeApiImpl implements ChangeApi {
}
}
@Override
public void move(String destination) throws RestApiException {
MoveInput in = new MoveInput();
in.destination_branch = destination;
move(in);
}
@Override
public void move(MoveInput in) throws RestApiException {
try {
move.apply(change, in);
} catch (OrmException | UpdateException e) {
throw new RestApiException("Cannot move change", e);
}
}
@Override
public ChangeApi revert() throws RestApiException {
return revert(new RevertInput());

View File

@@ -71,6 +71,7 @@ public class Module extends RestApiModule {
post(CHANGE_KIND, "rebase").to(Rebase.CurrentRevision.class);
post(CHANGE_KIND, "index").to(Index.class);
post(CHANGE_KIND, "rebuild.notedb").to(Rebuild.class);
post(CHANGE_KIND, "move").to(Move.class);
post(CHANGE_KIND, "reviewers").to(PostReviewers.class);
get(CHANGE_KIND, "suggest_reviewers").to(SuggestChangeReviewers.class);

View File

@@ -0,0 +1,197 @@
// Copyright (C) 2015 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.change;
import static com.google.gerrit.server.query.change.ChangeData.asChanges;
import com.google.common.base.Strings;
import com.google.gerrit.common.TimeUtil;
import com.google.gerrit.extensions.api.changes.MoveInput;
import com.google.gerrit.extensions.common.ChangeInfo;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.gerrit.extensions.restapi.RestApiException;
import com.google.gerrit.extensions.restapi.RestModifyView;
import com.google.gerrit.reviewdb.client.Branch;
import com.google.gerrit.reviewdb.client.Change;
import com.google.gerrit.reviewdb.client.ChangeMessage;
import com.google.gerrit.reviewdb.client.Change.Status;
import com.google.gerrit.reviewdb.client.PatchSet;
import com.google.gerrit.reviewdb.client.Project;
import com.google.gerrit.reviewdb.client.RefNames;
import com.google.gerrit.reviewdb.server.ReviewDb;
import com.google.gerrit.server.ChangeMessagesUtil;
import com.google.gerrit.server.ChangeUtil;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.PatchSetUtil;
import com.google.gerrit.server.git.BatchUpdate;
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.git.UpdateException;
import com.google.gerrit.server.notedb.ChangeUpdate;
import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
import com.google.gerrit.server.project.ChangeControl;
import com.google.gerrit.server.query.change.InternalChangeQuery;
import com.google.gwtorm.server.OrmException;
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.Singleton;
import org.eclipse.jgit.errors.RepositoryNotFoundException;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevWalk;
import java.io.IOException;
@Singleton
public class Move implements RestModifyView<ChangeResource, MoveInput> {
private final Provider<ReviewDb> dbProvider;
private final ChangeJson.Factory json;
private final GitRepositoryManager repoManager;
private final Provider<InternalChangeQuery> queryProvider;
private final ChangeMessagesUtil cmUtil;
private final BatchUpdate.Factory batchUpdateFactory;
private final PatchSetUtil psUtil;
@Inject
Move(Provider<ReviewDb> dbProvider,
ChangeJson.Factory json,
GitRepositoryManager repoManager,
Provider<InternalChangeQuery> queryProvider,
ChangeMessagesUtil cmUtil,
BatchUpdate.Factory batchUpdateFactory,
PatchSetUtil psUtil) {
this.dbProvider = dbProvider;
this.json = json;
this.repoManager = repoManager;
this.queryProvider = queryProvider;
this.cmUtil = cmUtil;
this.batchUpdateFactory = batchUpdateFactory;
this.psUtil = psUtil;
}
@Override
public ChangeInfo apply(ChangeResource req, MoveInput input)
throws RestApiException, OrmException, UpdateException {
ChangeControl control = req.getControl();
input.destination_branch = RefNames.fullName(input.destination_branch);
if (!control.canMoveTo(input.destination_branch, dbProvider.get())) {
throw new AuthException("Move not permitted");
}
try (BatchUpdate u = batchUpdateFactory.create(dbProvider.get(),
req.getChange().getProject(), control.getUser(), TimeUtil.nowTs())) {
u.addOp(req.getChange().getId(), new Op(control, input));
u.execute();
}
return json.create(ChangeJson.NO_OPTIONS).format(req.getChange());
}
private class Op extends BatchUpdate.Op {
private final MoveInput input;
private final IdentifiedUser caller;
private Change change;
private Branch.NameKey newDestKey;
public Op(ChangeControl ctl, MoveInput input) {
this.input = input;
this.caller = ctl.getUser().asIdentifiedUser();
}
@Override
public boolean updateChange(ChangeContext ctx) throws OrmException,
ResourceConflictException, RepositoryNotFoundException, IOException {
change = ctx.getChange();
if (change.getStatus() != Status.NEW
&& change.getStatus() != Status.DRAFT) {
throw new ResourceConflictException("Change is " + status(change));
}
Project.NameKey projectKey = change.getProject();
newDestKey = new Branch.NameKey(projectKey, input.destination_branch);
Branch.NameKey changePrevDest = change.getDest();
if (changePrevDest.equals(newDestKey)) {
throw new ResourceConflictException(
"Change is already destined for the specified branch");
}
final PatchSet.Id patchSetId = change.currentPatchSetId();
try (Repository repo = repoManager.openRepository(projectKey);
RevWalk revWalk = new RevWalk(repo)) {
RevCommit currPatchsetRevCommit = revWalk.parseCommit(
ObjectId.fromString(psUtil.current(ctx.getDb(), ctx.getNotes())
.getRevision().get()));
if (currPatchsetRevCommit.getParentCount() > 1) {
throw new ResourceConflictException("Merge commit cannot be moved");
}
ObjectId refId = repo.resolve(input.destination_branch);
// Check if destination ref exists in project repo
if (refId == null) {
throw new ResourceConflictException(
"Destination " + input.destination_branch + " not found in the project");
}
RevCommit refCommit = revWalk.parseCommit(refId);
if (revWalk.isMergedInto(currPatchsetRevCommit, refCommit)) {
throw new ResourceConflictException(
"Current patchset revision is reachable from tip of "
+ input.destination_branch);
}
}
Change.Key changeKey = change.getKey();
if (!asChanges(queryProvider.get().byBranchKey(newDestKey, changeKey))
.isEmpty()) {
throw new ResourceConflictException(
"Destination " + newDestKey.getShortName()
+ " has a different change with same change key " + changeKey);
}
if (!change.currentPatchSetId().equals(patchSetId)) {
throw new ResourceConflictException("Patch set is not current");
}
ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
update.setBranch(newDestKey.get());
change.setDest(newDestKey);
StringBuilder msgBuf = new StringBuilder();
msgBuf.append("Change destination moved from ");
msgBuf.append(changePrevDest.getShortName());
msgBuf.append(" to ");
msgBuf.append(newDestKey.getShortName());
if (!Strings.isNullOrEmpty(input.message)) {
msgBuf.append("\n\n");
msgBuf.append(input.message);
}
ChangeMessage cmsg = new ChangeMessage(
new ChangeMessage.Key(change.getId(),
ChangeUtil.messageUUID(ctx.getDb())),
caller.getAccountId(), ctx.getWhen(), change.currentPatchSetId());
cmsg.setMessage(msgBuf.toString());
cmUtil.addChangeMessage(ctx.getDb(), update, cmsg);
ctx.saveChange();
return true;
}
}
private static String status(Change change) {
return change != null ? change.getStatus().name().toLowerCase() : "deleted";
}
}

View File

@@ -236,6 +236,12 @@ public class ChangeControl {
) && !isPatchSetLocked(db);
}
/** Can this user change the destination branch of this change
to the new ref? */
public boolean canMoveTo(String ref, ReviewDb db) throws OrmException {
return getProjectControl().controlForRef(ref).canUpload() && canAbandon(db);
}
/** Can this user publish this draft change or any draft patch set of this change? */
public boolean canPublish(final ReviewDb db) throws OrmException {
return (isOwner() || getRefControl().canPublishDrafts())