package org.jenkinsci.plugins.ghprb;
import com.google.common.annotations.VisibleForTesting;
import hudson.Extension;
import hudson.FilePath;
import hudson.Launcher;
import hudson.model.*;
import hudson.tasks.BuildStepDescriptor;
import hudson.tasks.BuildStepMonitor;
import hudson.tasks.Publisher;
import hudson.tasks.Recorder;
import hudson.util.FormValidation;
import jenkins.tasks.SimpleBuildStep;
import org.kohsuke.github.*;
import org.kohsuke.github.GHPullRequestCommitDetail.Commit;
import org.kohsuke.stapler.AncestorInPath;
import org.kohsuke.stapler.DataBoundConstructor;
import org.kohsuke.stapler.QueryParameter;
import javax.annotation.Nonnull;
import java.io.IOException;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
public class GhprbPullRequestMerge extends Recorder implements SimpleBuildStep {
private transient TaskListener listener;
private final Boolean onlyAdminsMerge;
private final Boolean disallowOwnCode;
private final String mergeComment;
private final Boolean failOnNonMerge;
private final Boolean deleteOnMerge;
private final Boolean allowMergeWithoutTriggerPhrase;
@DataBoundConstructor
public GhprbPullRequestMerge(String mergeComment, boolean onlyAdminsMerge, boolean disallowOwnCode, boolean failOnNonMerge,
boolean deleteOnMerge, boolean allowMergeWithoutTriggerPhrase) {
this.mergeComment = mergeComment;
this.onlyAdminsMerge = onlyAdminsMerge;
this.disallowOwnCode = disallowOwnCode;
this.failOnNonMerge = failOnNonMerge;
this.deleteOnMerge = deleteOnMerge;
this.allowMergeWithoutTriggerPhrase = allowMergeWithoutTriggerPhrase;
}
public String getMergeComment() {
return mergeComment;
}
public boolean getOnlyAdminsMerge() {
return onlyAdminsMerge == null ? false : onlyAdminsMerge;
}
public boolean getDisallowOwnCode() {
return disallowOwnCode == null ? false : disallowOwnCode;
}
public boolean getFailOnNonMerge() {
return failOnNonMerge == null ? false : failOnNonMerge;
}
public boolean getDeleteOnMerge() {
return deleteOnMerge == null ? false : deleteOnMerge;
}
public Boolean getAllowMergeWithoutTriggerPhrase() {
return allowMergeWithoutTriggerPhrase == null ? Boolean.valueOf(false) : allowMergeWithoutTriggerPhrase;
}
public BuildStepMonitor getRequiredMonitorService() {
return BuildStepMonitor.BUILD;
}
private transient GhprbTrigger trigger;
private transient Ghprb helper;
private transient GhprbCause cause;
private transient GHPullRequest pr;
@VisibleForTesting
void setHelper(Ghprb helper) {
this.helper = helper;
}
@Override
public void perform(@Nonnull Run<?, ?> run, @Nonnull FilePath filePath, @Nonnull Launcher launcher, @Nonnull TaskListener taskListener) throws InterruptedException, IOException {
listener = taskListener;
Job<?, ?> project = run.getParent();
if (run.getResult().isWorseThan(Result.SUCCESS)) {
listener.getLogger().println("Build did not succeed, merge will not be run");
return;
}
trigger = Ghprb.extractTrigger(project);
if (trigger == null)
return;
cause = Ghprb.getCause(run);
if (cause == null) {
return;
}
pr = trigger.getRepository().getActualPullRequest(cause.getPullID());
if (helper == null) {
helper = new Ghprb(trigger);
}
GHUser triggerSender = cause.getTriggerSender();
// ignore comments from bot user, this fixes an issue where the bot would auto-merge
// a PR when the 'request for testing' phrase contains the PR merge trigger phrase and
// the bot is a member of a whitelisted organization
if (helper.isBotUser(triggerSender)) {
listener.getLogger().println("Comment from bot user " + triggerSender.getLogin() + " ignored.");
return;
}
boolean intendToMerge = false;
boolean canMerge = true;
String commentBody = cause.getCommentBody();
// If merge can only be triggered by a comment and there is a comment
if (!getAllowMergeWithoutTriggerPhrase() && (commentBody == null || !helper.isTriggerPhrase(commentBody))) {
listener.getLogger().println("The comment does not contain the required trigger phrase.");
} else {
intendToMerge = true;
}
// If there is no intention to merge there is no point checking
if (intendToMerge && getOnlyAdminsMerge() && (triggerSender == null || !helper.isAdmin(triggerSender))) {
canMerge = false;
listener.getLogger().println("Only admins can merge this pull request, " + (triggerSender != null ? triggerSender.getLogin() + " is not an admin" : " and build was triggered via automation") + ".");
if (triggerSender != null) {
commentOnRequest(String.format("Code not merged because @%s (%s) is not in the Admin list.", triggerSender.getLogin(), triggerSender.getName()));
}
}
// If there is no intention to merge there is no point checking
if (intendToMerge && getDisallowOwnCode() && (triggerSender == null || isOwnCode(pr, triggerSender))) {
canMerge = false;
if (triggerSender != null) {
listener.getLogger().println("The commentor is also one of the contributors.");
commentOnRequest(String.format("Code not merged because @%s (%s) has committed code in the request.", triggerSender.getLogin(), triggerSender.getName()));
}
}
Boolean isMergeable = cause.isMerged();
// The build should not fail if no merge is expected
if (intendToMerge && canMerge && (isMergeable == null || !isMergeable)) {
listener.getLogger().println("Pull request cannot be automerged.");
commentOnRequest("Pull request is not mergeable.");
listener.error(Result.FAILURE.toString());
return;
}
if (intendToMerge && canMerge) {
listener.getLogger().println("Merging the pull request");
try {
Field ghRootField = GHIssue.class.getDeclaredField("root");
ghRootField.setAccessible(true);
Object ghRoot = ghRootField.get(pr);
Method anonMethod = GitHub.class.getMethod("isAnonymous");
anonMethod.setAccessible(true);
Boolean isAnonymous = (Boolean) (anonMethod.invoke(ghRoot));
listener.getLogger().println("Merging PR[" + pr + "] is anonymous: " + isAnonymous);
} catch (Exception e) {
e.printStackTrace(listener.getLogger());
}
String mergeComment = Ghprb.replaceMacros(run, listener, getMergeComment());
pr.merge(mergeComment);
listener.getLogger().println("Pull request successfully merged");
deleteBranch(run, launcher, listener);
}
// We should only fail the build if there is an intent to merge
if (intendToMerge && !canMerge && getFailOnNonMerge()) {
listener.error(Result.FAILURE.toString());
return;
}
}
private void deleteBranch(Run<?, ?> build, Launcher launcher, final TaskListener listener) {
if (!getDeleteOnMerge()) {
return;
}
String branchName = pr.getHead().getRef();
try {
GHRepository repo = pr.getRepository();
GHRef ref = repo.getRef("heads/" + branchName);
ref.delete();
listener.getLogger().println("Deleted branch " + branchName);
} catch (IOException e) {
listener.getLogger().println("Unable to delete branch " + branchName);
e.printStackTrace(listener.getLogger());
}
}
private void commentOnRequest(String comment) {
try {
trigger.getRepository().addComment(pr.getNumber(), comment);
} catch (Exception e) {
listener.getLogger().println("Failed to add comment");
e.printStackTrace(listener.getLogger());
}
}
private boolean isOwnCode(GHPullRequest pr, GHUser commentor) {
try {
String commentorName = commentor.getName();
String commentorEmail = commentor.getEmail();
String commentorLogin = commentor.getLogin();
GHUser prUser = pr.getUser();
if (prUser.getLogin().equals(commentorLogin)) {
listener.getLogger().println(commentorName + " (" + commentorLogin + ") has submitted the PR[" + pr.getNumber() + pr.getNumber() + "] that is to be merged");
return true;
}
for (GHPullRequestCommitDetail detail : pr.listCommits()) {
Commit commit = detail.getCommit();
GitUser committer = commit.getCommitter();
String committerName = committer.getName();
String committerEmail = committer.getEmail();
boolean isSame = false;
isSame |= commentorName != null && commentorName.equals(committerName);
isSame |= commentorEmail != null && commentorEmail.equals(committerEmail);
if (isSame) {
listener.getLogger().println(commentorName + " (" + commentorEmail + ") has commits in PR[" + pr.getNumber() + "] that is to be merged");
return isSame;
}
}
} catch (IOException e) {
listener.getLogger().println("Unable to get committer name");
e.printStackTrace(listener.getLogger());
}
return false;
}
@Extension(ordinal = -1)
public static class DescriptorImpl extends BuildStepDescriptor<Publisher> {
@Override
public String getDisplayName() {
return "Github Pull Request Merger";
}
@Override
public boolean isApplicable(Class<? extends AbstractProject> aClass) {
return true;
}
public FormValidation doCheck(@AncestorInPath Job<?, ?> project, @QueryParameter String value) throws IOException {
FilePath buildDirectory = new FilePath(project.getBuildDir());
return FilePath.validateFileMask(buildDirectory, value);
}
}
}