// Copyright (C) 2011 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.sshd.commands; import static com.google.gerrit.sshd.commands.StagingCommand.R_BUILDS; import static com.google.gerrit.sshd.commands.StagingCommand.R_HEADS; import com.google.gerrit.common.ChangeHookRunner; import com.google.gerrit.common.data.ApprovalTypes; import com.google.gerrit.reviewdb.ApprovalCategoryValue; import com.google.gerrit.reviewdb.Branch; import com.google.gerrit.reviewdb.Change; import com.google.gerrit.reviewdb.ChangeSet; import com.google.gerrit.reviewdb.PatchSet; import com.google.gerrit.reviewdb.Project; import com.google.gerrit.reviewdb.ReviewDb; import com.google.gerrit.reviewdb.Topic; import com.google.gerrit.server.ChangeUtil; import com.google.gerrit.server.IdentifiedUser; import com.google.gerrit.server.TopicUtil; import com.google.gerrit.server.git.GitRepositoryManager; import com.google.gerrit.server.git.MergeOp; import com.google.gerrit.server.git.MergeQueue; import com.google.gerrit.server.mail.BuildApprovedSender; import com.google.gerrit.server.mail.BuildRejectedSender; import com.google.gerrit.server.mail.EmailException; import com.google.gerrit.server.patch.PublishComments; import com.google.gerrit.server.project.CanSubmitResult; import com.google.gerrit.server.project.ChangeControl; import com.google.gerrit.server.project.InvalidChangeOperationException; import com.google.gerrit.server.project.NoSuchChangeException; import com.google.gerrit.server.project.NoSuchRefException; import com.google.gerrit.server.project.NoSuchTopicException; import com.google.gerrit.server.workflow.FunctionState; import com.google.gerrit.server.project.TopicControl; import com.google.gerrit.server.workflow.TopicFunctionState; import com.google.gerrit.sshd.BaseCommand; import com.google.gerrit.sshd.commands.StagingCommand.BranchNotFoundException; import com.google.gwtorm.client.AtomicUpdate; import com.google.gwtorm.client.OrmException; import com.google.inject.Inject; import org.apache.sshd.server.Environment; import org.eclipse.jgit.errors.RepositoryNotFoundException; import org.eclipse.jgit.lib.Ref; import org.eclipse.jgit.lib.RefUpdate; import org.eclipse.jgit.lib.Repository; import org.eclipse.jgit.revwalk.RevCommit; import org.eclipse.jgit.revwalk.RevSort; import org.eclipse.jgit.revwalk.RevWalk; import org.kohsuke.args4j.Option; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.io.PrintWriter; import java.util.HashSet; import java.util.List; /** * A command to report pass or fail status for builds. When a build receives * pass status, the branch is updated with build ref and all open changes in * the build are marked as merged. When a build receives fail status, all * change in the build are marked as new and they need to be staged again. *

* For example, how to approve a build * $ ssh -p 29418 localhost gerrit staging-approve -p project -b master -i 123 -r=pass */ public class StagingApprove extends BaseCommand { private static final Logger log = LoggerFactory.getLogger(StagingApprove.class); private class MergeException extends Exception { private static final long serialVersionUID = 1L; public MergeException(String message) { super(message); } public MergeException(String message, Throwable throwable) { super(message, throwable); } } /** Parameter value for pass result. */ private static final String PASS = "pass"; /** Parameter value for fail result. */ private static final String FAIL = "fail"; /** Parameter value for stdin message. */ private static final String STDIN_MESSAGE = "-"; @Inject private GitRepositoryManager gitManager; @Inject private ReviewDb db; @Inject private PublishComments.Factory publishCommentsFactory; @Inject private ChangeControl.Factory changeControlFactory; @Inject private TopicControl.Factory topicControlFactory; @Inject private ApprovalTypes approvalTypes; @Inject private FunctionState.Factory functionStateFactory; @Inject private IdentifiedUser currentUser; @Inject private ChangeHookRunner hooks; @Inject private MergeQueue merger; @Inject private MergeOp.Factory opFactory; @Inject private TopicFunctionState.Factory topicFunctionStateFactory; @Inject private BuildApprovedSender.Factory buildApprovedFactory; @Inject private BuildRejectedSender.Factory buildRejectedFactory; @Option(name = "--project", aliases = {"-p"}, required = true, usage = "project name") private String project; @Option(name = "--build-id", aliases = {"-i"}, required = true, usage = "build branch containing changes, e.g. refs/builds/123 or 123") private String buildBranch; @Option(name = "--result", aliases = {"-r"}, required = true, usage = "pass or fail") private String result; @Option(name = "--message", aliases = {"-m"}, metaVar ="-|MESSAGE", usage = "message added to all changes") private String message; @Option(name = "--branch", aliases = {"-b"}, metaVar = "BRANCH", required = true, usage = "destination branch, e.g. refs/heads/master or just master") private String branch; private Repository git; private List toApprove; private Branch.NameKey destination; @Override public void start(final Environment env) { startThread(new CommandRunnable() { @Override public void run() throws Exception { parseCommandLine(); StagingApprove.this.approve(); } }); } private void approve() throws UnloggedFailure { // Check result parameter value. final boolean passed; if (result.toLowerCase().equals(PASS)) { passed = true; } else if (result.toLowerCase().equals(FAIL)) { passed = false; } else { // A valid result parameter value was not used. throw new UnloggedFailure(1, "fatal: result argument accepts only value pass or fail."); } final PrintWriter stdout = toPrintWriter(out); // Name key for the build branch. Branch.NameKey buildBranchNameKey = StagingCommand.getNameKey(project, R_BUILDS, buildBranch); destination = StagingCommand.getNameKey(project, R_HEADS, branch); try { openRepository(project); // Initialize and populate open changes list. toApprove = StagingCommand.openChanges(git, db, buildBranchNameKey, destination); // Notify user that build did not have any open changes. The build has // already been approved. if (toApprove.isEmpty()) { throw new UnloggedFailure(1, "No open changes in the build branch"); } // Validate change status and destination branch. validateChanges(); // If result is passed, check that the user has required access rights // to submit changes. if (passed) { validateSubmitRights(); try { updateDestinationBranch(buildBranchNameKey); } catch (IOException e) { resetChangeStatus(); throw e; } catch (MergeException e) { resetChangeStatus(); throw e; } // Rebuild staging branch. ChangeUtil.rebuildStaging(destination, currentUser, db, git, opFactory, merger, hooks); } // Use current message or read it from stdin. prepareMessage(); // Iterate through each open change and publish message. for (PatchSet patchSet : toApprove) { final PatchSet.Id patchSetId = patchSet.getId(); publishMessage(patchSetId); if (passed) { // Set change status to merged. pass(patchSetId); } else { // Reset change status. reject(patchSetId); } } } catch (IOException e) { throw new UnloggedFailure(1, "fatal: Failed to update destination branch", e); } catch (OrmException e) { throw new UnloggedFailure(1, "fatal: Failed to access database", e); } catch (NoSuchChangeException e) { throw new UnloggedFailure(1, "fatal: Failed to validate access rights", e); } catch (BranchNotFoundException e) { throw new UnloggedFailure(1, "fatal: " + e.getMessage(), e); } catch (NoSuchRefException e) { throw new UnloggedFailure(1, "fatal: Failed to access change destination branch", e); } catch (MergeException e) { throw new UnloggedFailure(1, "fatal: " + e.getMessage(), e); } catch (InvalidChangeOperationException e) { throw new UnloggedFailure(1, "fatal: Failed to publish comments", e); } catch (IllegalStateException e) { throw new UnloggedFailure(1, "fatal: Changes are missing required approvals: " + e.getMessage(), e); } catch (NoSuchTopicException e) { throw new UnloggedFailure(1, "fatal: Invalid topic: " + e.getMessage(), e); } finally { stdout.flush(); if (git != null) { git.close(); } } } private void validateChanges() throws OrmException, UnloggedFailure { for (PatchSet patchSet : toApprove) { final Change change = db.changes().get(patchSet.getId().getParentKey()); // All changes must be in state INTEGRATING. if (change.getStatus() != Change.Status.INTEGRATING) { throw new UnloggedFailure(1, "Change not in INTEGRATING state (" + change.getKey() + ")"); } } } private void openRepository(final String project) throws RepositoryNotFoundException { Project.NameKey projectNameKey = new Project.NameKey(project); git = gitManager.openRepository(projectNameKey); } private void validateSubmitRights() throws UnloggedFailure, NoSuchChangeException, OrmException, NoSuchTopicException { for (PatchSet patchSet : toApprove) { final Change.Id changeId = patchSet.getId().getParentKey(); final Change change = db.changes().get(changeId); final Topic.Id topicId = change.getTopicId(); // Check only topic status for changes in topic. if (topicId != null) { // Change is part of a topic. Validate the topic with // TopicChangeControl. final TopicControl topicControl = topicControlFactory.validateFor(topicId); Topic topic = db.topics().get(topicId); // Only validate most current change set of topic ChangeSet changeSet = db.changeSets().get(topic.currentChangeSetId()); CanSubmitResult result = topicControl.canSubmit(db, changeSet.getId(), approvalTypes, topicFunctionStateFactory); if (result != CanSubmitResult.OK) { throw new UnloggedFailure(1, result.getMessage()); } // List changeSets = db.changeSets().byTopic(topicId).toList(); // for (ChangeSet changeSet : changeSets) { // CanSubmitResult result = // topicControl.canSubmit(db, changeSet.getId(), approvalTypes, // topicFunctionStateFactory); // if (result != CanSubmitResult.OK) { // throw new UnloggedFailure(1, result.getMessage()); // } // } } else { // Change is not part of a topic. Validate it with ChangeControl. final ChangeControl changeControl = changeControlFactory.validateFor(changeId); CanSubmitResult result = changeControl.canSubmit(patchSet.getId(), db, approvalTypes, functionStateFactory); if (result != CanSubmitResult.OK) { throw new UnloggedFailure(1, result.getMessage()); } } } } private void publishMessage(final PatchSet.Id patchSetId) throws NoSuchChangeException, OrmException, NoSuchRefException, IOException, InvalidChangeOperationException { if (message != null && message.length() > 0) { publishCommentsFactory.create(patchSetId, message, new HashSet()).call(); } } private void updateDestinationBranch(final Branch.NameKey buildBranchKey) throws IOException, MergeException { RevWalk rw = null; try { // Setup RevWalk. rw = new RevWalk(git); rw.sort(RevSort.TOPO); rw.sort(RevSort.COMMIT_TIME_DESC, true); // Prepare branch update. Set destination branch tip as old object id. RefUpdate branchUpdate = git.updateRef(destination.get()); // Access tip of build branch. Ref buildRef = git.getRef(buildBranchKey.get()); // Access commits from destination and build branches. RevCommit branchTip = rw.parseCommit(branchUpdate.getOldObjectId()); RevCommit buildTip = rw.parseCommit(buildRef.getObjectId()); // Setup branch update. branchUpdate.setForceUpdate(false); // We are updating old destination branch tip to build branch tip. branchUpdate.setNewObjectId(buildTip); // Make sure that the build tip is reachable from the branch tip. if (!rw.isMergedInto(branchTip, buildTip)) { throw new MergeException(destination.get() + " is not reachable from " + buildBranchKey.get()); } // Update destination branch. switch (branchUpdate.update(rw)) { // Only fast-forward result is reported as success. case FAST_FORWARD: hooks.doRefUpdatedHook(destination, branchUpdate, currentUser.getAccount()); try { sendBuildApprovedMails(); } catch (Exception e) { log.error("Failed to send change merged e-mails", e); } break; default: try { sendBuildRejectedMails(); } catch (Exception e) { log.error("Failed to send change merged e-mails", e); } throw new MergeException("Could not fast-forward build to destination branch"); } } finally { if (rw != null) { rw.dispose(); } } } private void pass(final PatchSet.Id patchSetId) throws OrmException { // Update change status from INTEGRATING to MERGED. ChangeUtil.setIntegratingToMerged(patchSetId, currentUser, db); Change change = db.changes().get(patchSetId.getParentKey()); Topic.Id topicId = change.getTopicId(); if (topicId != null) { Topic topic = db.topics().get(topicId); if (topic.getStatus() != Topic.Status.MERGED) { TopicUtil.setIntegratingToMerged(topicId, db); } } } private void reject(final PatchSet.Id patchSetId) throws OrmException, IOException { // Remove staging approval and update status from INTEGRATING to NEW. ChangeUtil.rejectStagedChange(patchSetId, currentUser, db); Change change = db.changes().get(patchSetId.getParentKey()); Topic.Id topicId = change.getTopicId(); if (topicId != null) { Topic topic = db.topics().get(topicId); if (topic.getStatus() != Topic.Status.NEW) { TopicUtil.setIntegratingToNew(topicId, db); } } } private void prepareMessage() throws IOException { // No message given. if (message == null) { return; } // User will submit message through stdin. if (message.equals(STDIN_MESSAGE)) { // Clear stdin indicator. message = ""; // Read message from stdin. BufferedReader stdin = new BufferedReader(new InputStreamReader(in, "UTF-8")); String line; while ((line = stdin.readLine()) != null) { message += line + "\n"; } } // Else, use current message value. } private void resetChangeStatus() throws MergeException { // Return changes to staging branch. for (PatchSet patchSet : toApprove) { try { // Reset status of changes. final PatchSet.Id patchSetId = patchSet.getId(); final Change.Id changeId = patchSetId.getParentKey(); AtomicUpdate atomicUpdate = new AtomicUpdate() { @Override public Change update(Change change) { if (change.getStatus() == Change.Status.INTEGRATING) { change.setStatus(Change.Status.STAGING); ChangeUtil.updated(change); } return change; } }; db.changes().atomicUpdate(changeId, atomicUpdate); } catch (Exception e) { // Failed to reset change status. } } try { ChangeUtil.rebuildStaging(destination, currentUser, db, git, opFactory, merger, hooks); } catch (Exception e) { throw new MergeException("fatal: Failed to rebuild staging branch after failed fast-forward", e); } } private void sendBuildApprovedMails() throws OrmException, EmailException { for (PatchSet patchSet : toApprove) { final PatchSet.Id patchSetId = patchSet.getId(); final Change.Id changeId = patchSetId.getParentKey(); final Change change = db.changes().get(changeId); final BuildApprovedSender sender = buildApprovedFactory.create(change); sender.setFrom(currentUser.getAccountId()); sender.setPatchSet(patchSet); sender.send(); } } private void sendBuildRejectedMails() throws OrmException, EmailException { for (PatchSet patchSet : toApprove) { final PatchSet.Id patchSetId = patchSet.getId(); final Change.Id changeId = patchSetId.getParentKey(); final Change change = db.changes().get(changeId); final BuildRejectedSender sender = buildRejectedFactory.create(change); sender.setFrom(currentUser.getAccountId()); sender.setPatchSet(patchSet); sender.send(); } } }