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:

committed by
Nasser Grainawi

parent
eedd8a3b9b
commit
791f339090
@@ -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
|
||||
|
@@ -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();
|
||||
|
@@ -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,
|
||||
|
@@ -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,
|
||||
|
@@ -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;
|
||||
}
|
||||
}
|
@@ -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());
|
||||
|
@@ -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();
|
||||
|
@@ -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;
|
||||
}
|
@@ -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());
|
||||
|
@@ -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);
|
||||
|
@@ -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";
|
||||
}
|
||||
}
|
@@ -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())
|
||||
|
Reference in New Issue
Block a user