// Copyright (C) 2011 The Android Open Source Project
// Copyright (C) 2014 Digia Plc and/or its subsidiary(-ies).
//
// 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.common.collect.Lists;
import com.google.gerrit.common.ChangeHookRunner;
import com.google.gerrit.common.data.LabelTypes;
import com.google.gerrit.common.errors.EmailException;
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.PatchSet;
import com.google.gerrit.reviewdb.client.PatchSetApproval;
import com.google.gerrit.reviewdb.client.Project;
import com.google.gerrit.reviewdb.client.Project.SubmitType;
import com.google.gerrit.reviewdb.client.RevId;
import com.google.gerrit.reviewdb.server.ReviewDb;
import com.google.gerrit.server.ChangeUtil;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.change.PostReview.NotifyHandling;
import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
import com.google.gerrit.server.git.GitRepositoryManager;
import com.google.gerrit.server.git.MergeQueue;
import com.google.gerrit.server.mail.AbandonedSender;
import com.google.gerrit.server.mail.BuildApprovedSender;
import com.google.gerrit.server.mail.BuildRejectedSender;
import com.google.gerrit.server.mail.CommentSender;
import com.google.gerrit.server.mail.ReplyToChangeSender;
import com.google.gerrit.server.patch.PatchSetInfoFactory;
import com.google.gerrit.server.project.ChangeControl;
import com.google.gerrit.server.project.NoSuchChangeException;
import com.google.gerrit.server.project.NoSuchProjectException;
import com.google.gerrit.server.project.ProjectControl;
import com.google.gerrit.sshd.CommandMetaData;
import com.google.gerrit.sshd.SshCommand;
import com.google.gerrit.sshd.commands.StagingCommand.BranchNotFoundException;
import com.google.gwtorm.server.OrmException;
import com.google.inject.Inject;
import org.eclipse.jgit.lib.ObjectId;
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.sql.Timestamp;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
/**
* 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
*/
@CommandMetaData(name = "staging-approve", descr = "Report pass or fail status for builds.")
public class StagingApprove extends SshCommand {
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);
}
}
/** 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 ChangeControl.Factory changeControlFactory;
@Inject
private ProjectControl.Factory projectControlFactory;
@Inject
private IdentifiedUser currentUser;
@Inject
private ChangeHookRunner hooks;
@Inject
private MergeQueue merger;
@Inject
private BuildApprovedSender.Factory buildApprovedFactory;
@Inject
private BuildRejectedSender.Factory buildRejectedFactory;
@Inject
private AbandonedSender.Factory abandonedSenderFactory;
@Inject
private CommentSender.Factory commentSenderFactory;
@Inject
private PatchSetInfoFactory patchSetInfoFactory;
@Inject
private GitReferenceUpdated gitRefUpdated;
@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;
private List emailMessages;
@Override
protected void run() throws UnloggedFailure {
StagingApprove.this.approve();
}
private void approve() throws UnloggedFailure {
// Check result parameter value.
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");
}
// If result is passed, check that the user has required access rights
// to push changes.
if (passed) {
validatePushRights(project);
}
// Create list for possible email notification messages sent to user at the end
List emailMessages = new ArrayList();
// Use current message or read it from stdin.
prepareMessage();
// Start database transaction and execute all database operations
// before doing any merges on git. If git merge fails we can rollback
// everything.
//
// Parameter for this function is used for nothing, so null is fine
db.changes().beginTransaction(null);
// Iterate through each open change and publish message.
for (Map.Entry item : toApprove) {
PatchSet patchSet = item.getKey();
PatchSet.Id patchSetId = patchSet.getId();
// If change not in state INTEGRATING it will be abandoned
final Change change = db.changes().get(patchSetId.getParentKey());
if (change.getStatus() != Change.Status.INTEGRATING) {
abandonMessage(patchSet, getAbandonMessage(change));
abandon(patchSetId);
continue;
}
// Create a new patchset for merged commit to be consistent
// with cherry-pick submit behavior
if (passed && isSubmitTypeCherryPick(project)) {
patchSet = createNewPatchSetForMergedCommit(change, patchSet, item.getValue());
patchSetId = patchSet.getId();
}
// Publish message but only send mail if not passed
publishMessage(patchSet, !passed);
if (passed) {
// Set change status to merged.
pass(patchSetId);
} else {
// Reset change status.
reject(patchSetId);
}
}
// Fast forward destination branch to build branch
if (passed) {
updateDestinationBranch(buildBranchNameKey);
}
db.commit();
// Send email notifications, log errors
for (ReplyToChangeSender item : emailMessages) {
try {
item.send();
} catch (EmailException e) {
log.error("Cannot send comments by email", e);
}
}
if (passed) {
// Rebuild staging branch.
try {
ChangeUtil.rebuildStaging(destination, currentUser, db, git, merger, hooks);
} catch (Exception e) {
log.error("Failed to update staging branch", e);
}
}
} catch (IOException e) {
throw new UnloggedFailure(1, "fatal: " + e.getMessage(), e);
} catch (OrmException e) {
throw new UnloggedFailure(1, "fatal: Failed to access database", e);
} catch (MergeException e) {
throw new UnloggedFailure(1, "fatal: Failed to update destination branch", e);
} catch (NoSuchProjectException e) { // Invalid project name passed
throw new UnloggedFailure(1, "fatal: Failed to access project", e);
} catch (BranchNotFoundException e) { // Invalid branch name passed
throw new UnloggedFailure(1, "fatal: " + e.getMessage(), e);
} finally {
try {
db.rollback();
} catch (OrmException e) {
log.error("Failed to roll back transaction", e);
}
stdout.flush();
if (git != null) {
git.close();
}
}
}
private String getAbandonMessage(Change change) throws OrmException {
// Search all changes from database where change id matches to incoming one.
// Pick first merged one where destination doesn't match and use that for
// abandon message.
String source_branch = "other branch"; // This is used if branch name not found
List changes = db.changes().byKey(change.getKey()).toList();
for (Change c : changes) {
if (!c.getDest().equals(destination)
&& c.getStatus() == Change.Status.MERGED) {
source_branch = "branch '"
+ StagingCommand.getShortNameKey(project, R_HEADS, c.getDest().get()).get()
+ "'";
break;
}
}
String abandonMessage =
"This change has been abandoned because it was already integrated in "
+ source_branch + " which was merged into branch '"
+ destination.getShortName() +"'.";
// Add original message also to help solving the problem
if (message != null && message.length() > 0) {
abandonMessage += "\n\n" + message;
}
return abandonMessage;
}
private void openRepository(final String project) throws IOException {
Project.NameKey projectNameKey = new Project.NameKey(project);
git = gitManager.openRepository(projectNameKey);
}
private void validatePushRights(final String project) throws UnloggedFailure,
NoSuchProjectException {
Project.NameKey projectNameKey = new Project.NameKey(project);
final ProjectControl projectControl = projectControlFactory.validateFor(projectNameKey);
if (!projectControl.controlForRef(destination).canUpdate()) {
throw new UnloggedFailure(1, "No Push right to " + destination);
}
}
private void abandonMessage(final PatchSet patchSet, final String msg)
throws OrmException {
createChangeMessage(patchSet, true, msg, true);
}
private void publishMessage(final PatchSet patchSet, final boolean sendMail)
throws OrmException {
createChangeMessage(patchSet, sendMail, message, false);
}
private void createChangeMessage(final PatchSet patchSet, final boolean sendMail,
final String msg, final boolean isAbandon)
throws OrmException {
if (msg != null && msg.length() > 0) {
final PatchSet.Id patchSetId = patchSet.getId();
Change change = db.changes().get(patchSetId.getParentKey());
final ChangeMessage cmsg =
new ChangeMessage(new ChangeMessage.Key(change.getId(),
ChangeUtil.messageUUID(db)), currentUser.getAccountId(), patchSetId);
cmsg.setMessage(msg);
db.changeMessages().insert(Collections.singleton(cmsg));
if (sendMail) {
ReplyToChangeSender rtcs = null;
if (!isAbandon) {
rtcs = commentSenderFactory.create(NotifyHandling.ALL, change);
} else {
rtcs = abandonedSenderFactory.create(change);
}
rtcs.setFrom(currentUser.getAccountId());
rtcs.setPatchSet(patchSet);
rtcs.setChangeMessage(cmsg);
emailMessages.add(rtcs);
}
}
}
private void updateDestinationBranch(final Branch.NameKey buildBranchKey)
throws IOException, MergeException {
RevWalk revWalk = null;
try {
// Setup RevWalk.
revWalk = new RevWalk(git);
revWalk.sort(RevSort.TOPO);
revWalk.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 = revWalk.parseCommit(branchUpdate.getOldObjectId());
RevCommit buildTip = revWalk.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 (!revWalk.isMergedInto(branchTip, buildTip)) {
throw new MergeException(destination.get() + " is not reachable from "
+ buildBranchKey.get());
}
// Update destination branch.
RefUpdate.Result result = branchUpdate.update(revWalk);
switch (result) {
// Only fast-forward result is reported as success.
case FAST_FORWARD:
gitRefUpdated.fire(destination.getParentKey(), branchUpdate);
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 rejected e-mails", e);
}
throw new MergeException("Could not push build to destination branch");
}
} finally {
if (revWalk != null) {
revWalk.dispose();
}
}
}
private void pass(final PatchSet.Id patchSetId) throws OrmException {
// Update change status from INTEGRATING to MERGED.
ChangeUtil.setIntegratingToMerged(patchSetId, currentUser, db);
}
private void reject(final PatchSet.Id patchSetId) throws OrmException {
// Remove staging approval and update status from INTEGRATING to NEW.
ChangeUtil.rejectStagedChange(patchSetId, currentUser, db);
}
private void abandon(final PatchSet.Id patchSetId) throws OrmException {
// Update change status from INTEGRATING to ABANDONED.
ChangeUtil.setIntegratingToAbandoned(patchSetId, currentUser, 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 sendBuildApprovedMails() throws OrmException, EmailException, NoSuchChangeException {
for (Entry item : toApprove) {
final PatchSet patchSet = item.getKey();
final PatchSet.Id patchSetId = patchSet.getId();
final Change.Id changeId = patchSetId.getParentKey();
final Change change = db.changes().get(changeId);
final ChangeControl changeControl =
changeControlFactory.validateFor(changeId);
final LabelTypes lt = changeControl.getLabelTypes();
final BuildApprovedSender sender = buildApprovedFactory.create(lt, change);
sender.setBuildApprovedMessage(message);
sender.setFrom(currentUser.getAccountId());
sender.setPatchSet(patchSet);
sender.send();
}
}
private void sendBuildRejectedMails() throws OrmException, EmailException {
for (Entry item : toApprove) {
final PatchSet patchSet = item.getKey();
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();
}
}
private List getApprovalsForCommit(final PatchSet.Id psid) {
try {
List approvalList =
db.patchSetApprovals().byPatchSet(psid).toList();
Collections.sort(approvalList, new Comparator() {
@Override
public int compare(final PatchSetApproval a, final PatchSetApproval b) {
return a.getGranted().compareTo(b.getGranted());
}
});
return approvalList;
} catch (OrmException e) {
log.error("Can't read approval records for " + psid, e);
return Collections.emptyList();
}
}
private PatchSet createNewPatchSetForMergedCommit(final Change change,
final PatchSet patchSet, final RevCommit newCommit)
throws OrmException, IOException {
final PatchSet.Id patchSetId
= ChangeUtil.nextPatchSetId(git, change.currentPatchSetId());
PatchSet newPatchSet = new PatchSet(patchSetId);
newPatchSet.setCreatedOn(new Timestamp(System.currentTimeMillis()));
newPatchSet.setUploader(currentUser.getAccountId());
newPatchSet.setRevision(new RevId(newCommit.getId().name()));
ChangeUtil.insertAncestors(db, newPatchSet.getId(), newCommit);
db.patchSets().insert(Collections.singleton(newPatchSet));
change.setCurrentPatchSet(
patchSetInfoFactory.get(newCommit, newPatchSet.getId()));
db.changes().update(Collections.singletonList(change));
final List approvals = Lists.newArrayList();
for (PatchSetApproval a : getApprovalsForCommit(patchSet.getId())) {
approvals.add(new PatchSetApproval(newPatchSet.getId(), a));
}
db.patchSetApprovals().insert(approvals);
final RefUpdate ru;
ru = git.updateRef(newPatchSet.getRefName());
ru.setExpectedOldObjectId(ObjectId.zeroId());
ru.setNewObjectId(newCommit);
ru.disableRefLog();
final RevWalk rw = new RevWalk(git);
if (ru.update(rw) != RefUpdate.Result.NEW) {
throw new IOException(String.format(
"Failed to create ref %s in %s: %s", newPatchSet.getRefName(), change
.getDest().getParentKey().get(), ru.getResult()));
}
return newPatchSet;
}
private boolean isSubmitTypeCherryPick(final String project) throws NoSuchProjectException {
Project.NameKey projectNameKey = new Project.NameKey(project);
final ProjectControl projectControl = projectControlFactory.validateFor(projectNameKey);
return projectControl.getProject().getSubmitType().equals(SubmitType.CHERRY_PICK);
}
}