Line data Source code
1 : // Copyright (C) 2015 The Android Open Source Project 2 : // 3 : // Licensed under the Apache License, Version 2.0 (the "License"); 4 : // you may not use this file except in compliance with the License. 5 : // You may obtain a copy of the License at 6 : // 7 : // http://www.apache.org/licenses/LICENSE-2.0 8 : // 9 : // Unless required by applicable law or agreed to in writing, software 10 : // distributed under the License is distributed on an "AS IS" BASIS, 11 : // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 : // See the License for the specific language governing permissions and 13 : // limitations under the License. 14 : 15 : package com.google.gerrit.server.restapi.change; 16 : 17 : import static com.google.gerrit.extensions.conditions.BooleanCondition.and; 18 : import static com.google.gerrit.server.permissions.ChangePermission.ABANDON; 19 : import static com.google.gerrit.server.permissions.RefPermission.CREATE_CHANGE; 20 : import static com.google.gerrit.server.project.ProjectCache.illegalState; 21 : import static com.google.gerrit.server.query.change.ChangeData.asChanges; 22 : 23 : import com.google.common.base.Strings; 24 : import com.google.gerrit.common.Nullable; 25 : import com.google.gerrit.entities.BranchNameKey; 26 : import com.google.gerrit.entities.Change; 27 : import com.google.gerrit.entities.LabelType; 28 : import com.google.gerrit.entities.PatchSet; 29 : import com.google.gerrit.entities.PatchSetApproval; 30 : import com.google.gerrit.entities.Project; 31 : import com.google.gerrit.entities.RefNames; 32 : import com.google.gerrit.extensions.api.changes.MoveInput; 33 : import com.google.gerrit.extensions.common.ChangeInfo; 34 : import com.google.gerrit.extensions.restapi.AuthException; 35 : import com.google.gerrit.extensions.restapi.BadRequestException; 36 : import com.google.gerrit.extensions.restapi.MethodNotAllowedException; 37 : import com.google.gerrit.extensions.restapi.ResourceConflictException; 38 : import com.google.gerrit.extensions.restapi.Response; 39 : import com.google.gerrit.extensions.restapi.RestApiException; 40 : import com.google.gerrit.extensions.restapi.RestModifyView; 41 : import com.google.gerrit.extensions.webui.UiAction; 42 : import com.google.gerrit.server.ChangeMessagesUtil; 43 : import com.google.gerrit.server.ChangeUtil; 44 : import com.google.gerrit.server.IdentifiedUser; 45 : import com.google.gerrit.server.PatchSetUtil; 46 : import com.google.gerrit.server.approval.ApprovalsUtil; 47 : import com.google.gerrit.server.change.ChangeJson; 48 : import com.google.gerrit.server.change.ChangeResource; 49 : import com.google.gerrit.server.config.GerritServerConfig; 50 : import com.google.gerrit.server.git.GitRepositoryManager; 51 : import com.google.gerrit.server.notedb.ChangeUpdate; 52 : import com.google.gerrit.server.permissions.GlobalPermission; 53 : import com.google.gerrit.server.permissions.PermissionBackend; 54 : import com.google.gerrit.server.permissions.PermissionBackendException; 55 : import com.google.gerrit.server.project.ProjectCache; 56 : import com.google.gerrit.server.project.ProjectState; 57 : import com.google.gerrit.server.query.change.InternalChangeQuery; 58 : import com.google.gerrit.server.update.BatchUpdate; 59 : import com.google.gerrit.server.update.BatchUpdateOp; 60 : import com.google.gerrit.server.update.ChangeContext; 61 : import com.google.gerrit.server.update.UpdateException; 62 : import com.google.gerrit.server.util.time.TimeUtil; 63 : import com.google.inject.Inject; 64 : import com.google.inject.Provider; 65 : import com.google.inject.Singleton; 66 : import java.io.IOException; 67 : import java.util.Optional; 68 : import org.eclipse.jgit.lib.Config; 69 : import org.eclipse.jgit.lib.ObjectId; 70 : import org.eclipse.jgit.lib.Repository; 71 : import org.eclipse.jgit.revwalk.RevCommit; 72 : import org.eclipse.jgit.revwalk.RevWalk; 73 : 74 : @Singleton 75 : public class Move implements RestModifyView<ChangeResource, MoveInput>, UiAction<ChangeResource> { 76 : private final PermissionBackend permissionBackend; 77 : private final BatchUpdate.Factory updateFactory; 78 : private final ChangeJson.Factory json; 79 : private final GitRepositoryManager repoManager; 80 : private final Provider<InternalChangeQuery> queryProvider; 81 : private final ChangeMessagesUtil cmUtil; 82 : private final PatchSetUtil psUtil; 83 : private final ApprovalsUtil approvalsUtil; 84 : private final ProjectCache projectCache; 85 : private final boolean moveEnabled; 86 : 87 : @Inject 88 : Move( 89 : PermissionBackend permissionBackend, 90 : BatchUpdate.Factory updateFactory, 91 : ChangeJson.Factory json, 92 : GitRepositoryManager repoManager, 93 : Provider<InternalChangeQuery> queryProvider, 94 : ChangeMessagesUtil cmUtil, 95 : PatchSetUtil psUtil, 96 : ApprovalsUtil approvalsUtil, 97 : ProjectCache projectCache, 98 145 : @GerritServerConfig Config gerritConfig) { 99 145 : this.permissionBackend = permissionBackend; 100 145 : this.updateFactory = updateFactory; 101 145 : this.json = json; 102 145 : this.repoManager = repoManager; 103 145 : this.queryProvider = queryProvider; 104 145 : this.cmUtil = cmUtil; 105 145 : this.psUtil = psUtil; 106 145 : this.approvalsUtil = approvalsUtil; 107 145 : this.projectCache = projectCache; 108 145 : this.moveEnabled = gerritConfig.getBoolean("change", null, "move", true); 109 145 : } 110 : 111 : @Override 112 : public Response<ChangeInfo> apply(ChangeResource rsrc, MoveInput input) 113 : throws RestApiException, UpdateException, PermissionBackendException, IOException { 114 3 : if (!moveEnabled) { 115 : // This will be removed with the above config once we reach consensus for the move change 116 : // behavior. See: https://bugs.chromium.org/p/gerrit/issues/detail?id=9877 117 1 : throw new MethodNotAllowedException("move changes endpoint is disabled"); 118 : } 119 : 120 3 : Change change = rsrc.getChange(); 121 3 : Project.NameKey project = rsrc.getProject(); 122 3 : IdentifiedUser caller = rsrc.getUser().asIdentifiedUser(); 123 3 : if (input.destinationBranch == null) { 124 2 : throw new BadRequestException("destination branch is required"); 125 : } 126 2 : input.destinationBranch = RefNames.fullName(input.destinationBranch); 127 : 128 2 : if (!change.isNew()) { 129 1 : throw new ResourceConflictException("Change is " + ChangeUtil.status(change)); 130 : } 131 : 132 2 : BranchNameKey newDest = BranchNameKey.create(project, input.destinationBranch); 133 2 : if (change.getDest().equals(newDest)) { 134 1 : throw new ResourceConflictException("Change is already destined for the specified branch"); 135 : } 136 : 137 : // Not allowed to move if the current patch set is locked. 138 2 : psUtil.checkPatchSetNotLocked(rsrc.getNotes()); 139 : 140 : // Keeping all votes can be confusing in the context of the destination branch, see the 141 : // discussion in 142 : // https://gerrit-review.googlesource.com/c/gerrit/+/129171 143 : // Only administrators are allowed to keep all labels at their own risk. 144 2 : if (input.keepAllVotes 145 1 : && !permissionBackend.user(caller).test(GlobalPermission.ADMINISTRATE_SERVER)) { 146 1 : throw new AuthException("move is not permitted with keepAllVotes option"); 147 : } 148 : 149 : // Move requires abandoning this change, and creating a new change. 150 : try { 151 2 : rsrc.permissions().check(ABANDON); 152 2 : permissionBackend.user(caller).ref(newDest).check(CREATE_CHANGE); 153 1 : } catch (AuthException denied) { 154 1 : throw new AuthException("move not permitted", denied); 155 2 : } 156 2 : projectCache.get(project).orElseThrow(illegalState(project)).checkStatePermitsWrite(); 157 : 158 2 : Op op = new Op(input); 159 2 : try (BatchUpdate u = updateFactory.create(project, caller, TimeUtil.now())) { 160 2 : u.addOp(change.getId(), op); 161 2 : u.execute(); 162 : } 163 2 : return Response.ok(json.noOptions().format(op.getChange())); 164 : } 165 : 166 : private class Op implements BatchUpdateOp { 167 : private final MoveInput input; 168 : 169 : private Change change; 170 : private BranchNameKey newDestKey; 171 : 172 2 : Op(MoveInput input) { 173 2 : this.input = input; 174 2 : } 175 : 176 : @Nullable 177 : public Change getChange() { 178 2 : return change; 179 : } 180 : 181 : @Override 182 : public boolean updateChange(ChangeContext ctx) throws ResourceConflictException, IOException { 183 2 : change = ctx.getChange(); 184 2 : if (!change.isNew()) { 185 0 : throw new ResourceConflictException("Change is " + ChangeUtil.status(change)); 186 : } 187 : 188 2 : Project.NameKey projectKey = change.getProject(); 189 2 : newDestKey = BranchNameKey.create(projectKey, input.destinationBranch); 190 2 : BranchNameKey changePrevDest = change.getDest(); 191 2 : if (changePrevDest.equals(newDestKey)) { 192 0 : throw new ResourceConflictException("Change is already destined for the specified branch"); 193 : } 194 : 195 2 : final PatchSet.Id patchSetId = change.currentPatchSetId(); 196 2 : try (Repository repo = repoManager.openRepository(projectKey); 197 2 : RevWalk revWalk = new RevWalk(repo)) { 198 2 : RevCommit currPatchsetRevCommit = 199 2 : revWalk.parseCommit(psUtil.current(ctx.getNotes()).commitId()); 200 : 201 2 : ObjectId refId = repo.resolve(input.destinationBranch); 202 : // Check if destination ref exists in project repo 203 2 : if (refId == null) { 204 1 : throw new ResourceConflictException( 205 : "Destination " + input.destinationBranch + " not found in the project"); 206 : } 207 2 : RevCommit refCommit = revWalk.parseCommit(refId); 208 2 : if (revWalk.isMergedInto(currPatchsetRevCommit, refCommit)) { 209 1 : throw new ResourceConflictException( 210 : "Current patchset revision is reachable from tip of " + input.destinationBranch); 211 : } 212 : } 213 : 214 2 : Change.Key changeKey = change.getKey(); 215 2 : if (!asChanges(queryProvider.get().byBranchKey(newDestKey, changeKey)).isEmpty()) { 216 1 : throw new ResourceConflictException( 217 : "Destination " 218 1 : + newDestKey.shortName() 219 : + " has a different change with same change key " 220 : + changeKey); 221 : } 222 : 223 2 : if (!change.currentPatchSetId().equals(patchSetId)) { 224 0 : throw new ResourceConflictException("Patch set is not current"); 225 : } 226 : 227 2 : PatchSet.Id psId = change.currentPatchSetId(); 228 2 : ChangeUpdate update = ctx.getUpdate(psId); 229 2 : update.setBranch(newDestKey.branch()); 230 2 : change.setDest(newDestKey); 231 : 232 2 : if (!input.keepAllVotes) { 233 2 : updateApprovals(ctx, update, psId, projectKey); 234 : } 235 : 236 2 : StringBuilder msgBuf = new StringBuilder(); 237 2 : msgBuf.append("Change destination moved from "); 238 2 : msgBuf.append(changePrevDest.shortName()); 239 2 : msgBuf.append(" to "); 240 2 : msgBuf.append(newDestKey.shortName()); 241 2 : if (!Strings.isNullOrEmpty(input.message)) { 242 1 : msgBuf.append("\n\n"); 243 1 : msgBuf.append(input.message); 244 : } 245 2 : cmUtil.setChangeMessage(ctx, msgBuf.toString(), ChangeMessagesUtil.TAG_MOVE); 246 : 247 2 : return true; 248 : } 249 : 250 : /** 251 : * We have a long discussion about how to deal with its votes after moving a change from one 252 : * branch to another. In the end, we think only keeping the veto votes is the best way since 253 : * it's simple for us and less confusing for our users. See the discussion in the following 254 : * proposal: https://gerrit-review.googlesource.com/c/gerrit/+/129171 255 : */ 256 : private void updateApprovals( 257 : ChangeContext ctx, ChangeUpdate update, PatchSet.Id psId, Project.NameKey project) { 258 2 : for (PatchSetApproval psa : approvalsUtil.byPatchSet(ctx.getNotes(), psId)) { 259 2 : ProjectState projectState = projectCache.get(project).orElseThrow(illegalState(project)); 260 2 : Optional<LabelType> type = 261 2 : projectState.getLabelTypes(ctx.getNotes()).byLabel(psa.labelId()); 262 : // Only keep veto votes, defined as votes where: 263 : // 1- the label function allows minimum values to block submission. 264 : // 2- the vote holds the minimum value. 265 2 : if (!type.isPresent() 266 2 : || (type.get().isMaxNegative(psa) && type.get().getFunction().isBlock())) { 267 1 : continue; 268 : } 269 : 270 : // Remove votes from NoteDb. 271 2 : update.removeApprovalFor(psa.accountId(), psa.label()); 272 2 : } 273 2 : } 274 : } 275 : 276 : @Override 277 : public UiAction.Description getDescription(ChangeResource rsrc) throws IOException { 278 57 : UiAction.Description description = 279 : new UiAction.Description() 280 57 : .setLabel("Move Change") 281 57 : .setTitle("Move change to a different branch") 282 57 : .setVisible(false); 283 : 284 57 : Change change = rsrc.getChange(); 285 57 : if (!change.isNew()) { 286 25 : return description; 287 : } 288 52 : if (!projectCache 289 52 : .get(rsrc.getProject()) 290 52 : .orElseThrow(illegalState(rsrc.getProject())) 291 52 : .statePermitsWrite()) { 292 0 : return description; 293 : } 294 52 : if (psUtil.isPatchSetLocked(rsrc.getNotes())) { 295 0 : return description; 296 : } 297 52 : return description.setVisible( 298 52 : and( 299 52 : permissionBackend.user(rsrc.getUser()).ref(change.getDest()).testCond(CREATE_CHANGE), 300 52 : rsrc.permissions().testCond(ABANDON))); 301 : } 302 : }