package org.jenkinsci.plugins.ghprb; import com.google.common.base.Joiner; import hudson.model.Run; import org.apache.commons.lang.StringUtils; import org.kohsuke.github.*; import java.io.IOException; import java.net.URL; import java.util.Date; import java.util.List; import java.util.Set; import java.util.logging.Level; import java.util.logging.Logger; /** * Maintains state about a Pull Request for a particular Jenkins job. This is what understands the current state of a PR * for a particular job. * * @author Honza Brázdil jbrazdil@redhat.com */ public class GhprbPullRequest { private static final Logger logger = Logger.getLogger(GhprbPullRequest.class.getName()); @Deprecated @SuppressWarnings("unused") private transient GHUser author; @Deprecated @SuppressWarnings("unused") private transient String title; @Deprecated @SuppressWarnings("unused") private transient String reponame; @Deprecated @SuppressWarnings("unused") private transient URL url; @Deprecated @SuppressWarnings("unused") private transient String description; @Deprecated @SuppressWarnings("unused") private transient String target; @Deprecated @SuppressWarnings("unused") private transient String source; @Deprecated @SuppressWarnings("unused") private transient String authorRepoGitUrl; @Deprecated @SuppressWarnings("unused") private transient Boolean changed = true; private transient String authorEmail; private transient Ghprb helper; // will be refreshed each time GhprbRepository.init() is called private transient GhprbRepository repo; // will be refreshed each time GhprbRepository.init() is called private transient GHPullRequest pr; private transient GHUser triggerSender; // Only needed for a single build private transient GitUser commitAuthor; // Only needed for a single build private transient String commentBody; private transient boolean shouldRun = false; // Declares if we should run the build this time. private transient boolean triggered = false; // Only lets us know if the trigger phrase was used for this run private transient boolean mergeable = false; // Only works as an easy way to pass the value around for the start of // this build // Only useful for webhooks. We want to avoid excessive use of // Github API calls, specifically comment checks. In updatePR, we check // for comments that may have occurred between the previous update // and the current one. However, if we are using webhooks, we will always // receive these events directly from github. Unfortunately, simply avoiding // the comment check altogether when using webhooks will not be perfect // since a Jenkins restart could miss some comments. This flag indicates that // an initial comment check has been done and we can now operate in pure // webhook mode. private transient boolean initialCommentCheckDone = false; private final int id; private Date updated; // Needed to track when the PR was updated private String head; private String base; private boolean accepted = false; // Needed to see if the PR has been added to the accepted list private String lastBuildId; // Sets the updated time of the PR. If the updated time is newer, // return true, false otherwise. private boolean setUpdated(Date lastUpdateTime) { // Because there is no gaurantee of order of delivery, // we want to ensure that we do not set the updated time if it was // earlier than the current updated time if (updated == null || updated.compareTo(lastUpdateTime) < 0) { updated = lastUpdateTime; return true; } return false; } private void setHead(String newHead) { this.head = StringUtils.isEmpty(newHead) ? head : newHead; } private void setBase(String newBase) { this.base = StringUtils.isEmpty(newBase) ? base : newBase; } private void setAccepted(boolean shouldRun) { accepted = true; this.shouldRun = shouldRun; } public GhprbPullRequest(GHPullRequest pr, Ghprb ghprb, GhprbRepository repo) { id = pr.getNumber(); setPullRequest(pr); this.helper = ghprb; this.repo = repo; GHUser author = pr.getUser(); String reponame = repo.getName(); if (ghprb.isWhitelisted(author)) { setAccepted(true); } else { logger.log(Level.INFO, "Author of #{0} {1} on {2} not in whitelist!", new Object[] { id, author.getLogin(), reponame }); repo.addComment(id, GhprbTrigger.getDscp().getRequestForTestingPhrase()); } logger.log(Level.INFO, "Created Pull Request #{0} on {1} by {2} ({3}) updated at: {4} SHA: {5}", new Object[] { id, reponame, author.getLogin(), getAuthorEmail(), updated, this.head }); } public void init(Ghprb helper, GhprbRepository repo) { this.helper = helper; this.repo = repo; } /** * Checks this Pull Request representation against a GitHub version of the Pull Request, and triggers a build if * necessary. * * @param ghpr the pull request from github * @param isWebhook whether this is from a webhook or not */ public void check(GHPullRequest ghpr, boolean isWebhook) { if (helper.isProjectDisabled()) { logger.log(Level.FINE, "Project is disabled, ignoring pull request"); return; } // Call update PR with the update PR info and no comment updatePR(ghpr, null /*GHIssueComment*/, isWebhook); checkSkipBuild(); checkBlackListLabels(); checkWhiteListLabels(); tryBuild(); } private void checkBlackListLabels() { Set<String> labelsToIgnore = helper.getBlackListLabels(); if (labelsToIgnore != null && !labelsToIgnore.isEmpty()) { try { for (GHLabel label : pr.getLabels()) { if (labelsToIgnore.contains(label.getName())) { logger.log(Level.INFO, "Found label {0} in ignore list, pull request will be ignored.", label.getName()); shouldRun = false; } } } catch(IOException e) { logger.log(Level.SEVERE, "Failed to read blacklist labels", e); } } } private void checkWhiteListLabels() { Set<String> labelsMustContain = helper.getWhiteListLabels(); if (labelsMustContain != null && !labelsMustContain.isEmpty()) { boolean containsWhiteListLabel = false; try { for (GHLabel label : pr.getLabels()) { if (labelsMustContain.contains(label.getName())) { logger.log(Level.INFO, "Found label {0} in whitelist", label.getName()); containsWhiteListLabel = true; } } if (!containsWhiteListLabel) { logger.log(Level.INFO, "Can't find any of whitelist label."); shouldRun = false; } } catch(IOException e) { logger.log(Level.SEVERE, "Failed to read whitelist labels", e); } } } private void checkSkipBuild() { synchronized (this) { String skipBuildPhrase = helper.checkSkipBuild(this.pr); if (!StringUtils.isEmpty(skipBuildPhrase)) { logger.log(Level.INFO, "Pull request commented with {0} skipBuildPhrase. Hence skipping the build.", skipBuildPhrase); shouldRun = false; } } } public void check(GHIssueComment comment) { if (helper.isProjectDisabled()) { logger.log(Level.FINE, "Project is disabled, ignoring comment"); return; } updatePR(null /*GHPullRequest*/, comment, true); checkSkipBuild(); checkBlackListLabels(); checkWhiteListLabels(); tryBuild(); } // Reconcile the view of the PR we have locally with the one that was sent to us by GH. // We can reach this method in one of three ways, and the comment indicates what // we should do in each case: // 1. With webhooks + new trigger/PR initialization - // This could happen if a new job was added, new trigger was enabled, or if Jenkins // was restarted. In this case, our view of the PR is out of date. We need to // compare hashes and check the comments going back to when the last update was (which could be // when the PR was created). // 2. With webhooks + new comment/PR update - This is "normal" operation. In these // cases, we only need to process the comment that was just added, or compare hashes with // the updated PR info (for instance, if someone changes a title of a PR it shouldn't trigger. // We do NOT need to pull the comment info, since we will have gotten or will get // each comment. // 3. Without webhooks - In this case, we will always check comments and hashes until // the last update time. private void updatePR(GHPullRequest ghpr, GHIssueComment comment, boolean isWebhook) { // Get the updated time try { Date lastUpdateTime = updated; Date updatedDate = comment != null ? comment.getUpdatedAt() : ghpr.getUpdatedAt(); // Don't log unless it was actually updated if (updated == null || updated.compareTo(updatedDate) < 0) { String user = comment != null ? comment.getUser().getName(): ghpr.getUser().getName(); logger.log(Level.INFO, "Pull request #{0} was updated/initialized on {1} at {2} by {3} ({4})", new Object[] { this.id, this.repo.getName(), updatedDate, user, comment != null ? "comment" : "PR update"}); } synchronized (this) { boolean wasUpdated = setUpdated(updatedDate); // Update the PR object with the new pull request object if // it is non-null. getPullRequest will then avoid another // GH API call. if (ghpr != null) { setPullRequest(ghpr); } // Grab the pull request for use in this method (in case we came in through the comment path) GHPullRequest pullRequest = getPullRequest(); // the author of the PR could have been whitelisted since its creation if (!accepted && helper.isWhitelisted(getPullRequestAuthor())) { logger.log(Level.INFO, "Pull request #{0}'s author has been whitelisted", new Object[]{id}); setAccepted(false); } // If we were passed a comment and are receiving all the comments // as they come in (e.g. webhooks), then we don't need to do anything but // check that comment. Otherwise check the full set since the last // time we updated (which might have just happened). int commentsChecked = 0; if (wasUpdated && (!isWebhook || !initialCommentCheckDone)) { initialCommentCheckDone = true; commentsChecked = checkComments(pullRequest, lastUpdateTime); } else if (comment != null) { checkComment(comment); commentsChecked = 1; } // Check the commit on the PR against the recorded version. boolean newCommit = checkCommit(pullRequest); // Log some info. if (!newCommit && commentsChecked == 0) { logger.log(Level.INFO, "Pull request #{0} was updated on repo {1} but there aren''t any new comments nor commits; " + "that may mean that commit status was updated.", new Object[] { this.id, this.repo.getName() } ); } } } catch (IOException ex) { logger.log(Level.SEVERE, "Exception caught while updating the PR", ex); } } private boolean matchesAnyBranch(String target, List<GhprbBranch> branches) { for (GhprbBranch b : branches) { if (b.matches(target)) { // the target branch is in the whitelist! return true; } } return false; } // Determines whether a branch is an allowed target branch // // A branch is an allowed target branch if it matches a branch in the whitelist // but NOT any branches in the blacklist. public boolean isAllowedTargetBranch() { List<GhprbBranch> whiteListBranches = helper.getWhiteListTargetBranches(); List<GhprbBranch> blackListBranches = helper.getBlackListTargetBranches(); String target = getTarget(); // First check if it matches any whitelist branch. It matches if // the list is empty, or if it matches any branch in the list if (!whiteListBranches.isEmpty()) { if(!matchesAnyBranch(target, whiteListBranches)) { logger.log(Level.FINEST, "PR #{0} target branch: {1} isn''t in our whitelist of target branches: {2}", new Object[] { id, target, Joiner.on(',').skipNulls().join(whiteListBranches) }); return false; } } // We matched something in the whitelist, now check the blacklist. It must // not match any branch in the blacklist if (!blackListBranches.isEmpty()) { if(matchesAnyBranch(target, blackListBranches)) { logger.log(Level.FINEST, "PR #{0} target branch: {1} is in our blacklist of target branches: {2}", new Object[] { id, target, Joiner.on(',').skipNulls().join(blackListBranches) }); return false; } } return true; } private void tryBuild() { synchronized (this) { if (helper.isProjectDisabled()) { logger.log(Level.FINEST, "Project is disabled, not trying to build"); shouldRun = false; triggered = false; } if (helper.ifOnlyTriggerPhrase() && !triggered) { logger.log(Level.FINEST, "Trigger only phrase but we are not triggered"); shouldRun = false; } triggered = false; // Once we have decided that we are triggered then the flag should be set to false. if (!isAllowedTargetBranch()) { logger.log(Level.FINEST, "Branch is not whitelisted or is blacklisted, skipping the build"); return; } if (shouldRun) { shouldRun = false; // Change the shouldRun flag as soon as we decide to build. logger.log(Level.FINEST, "Running the build"); if (pr != null) { logger.log(Level.FINEST, "PR is not null, checking if mergable"); checkMergeable(); try { for (GHPullRequestCommitDetail commitDetails : pr.listCommits()) { if (commitDetails.getSha().equals(getHead())) { commitAuthor = commitDetails.getCommit().getCommitter(); break; } } } catch (Exception ex) { logger.log(Level.INFO, "Unable to get PR commits: ", ex); } } logger.log(Level.FINEST, "Running build..."); build(); } } } private void build() { GhprbBuilds builder = helper.getBuilds(); builder.build(this, triggerSender, commentBody); } // returns false if no new commit private boolean checkCommit(GHPullRequest pr) { GHCommitPointer head = pr.getHead(); GHCommitPointer base = pr.getBase(); String headSha = head.getSha(); String baseSha = base.getSha(); if (StringUtils.equals(headSha, this.head) && StringUtils.equals(baseSha, this.base)) { return false; } logger.log(Level.FINE, "New commit. Sha: Head[{0} => {1}] Base[{2} => {3}]", new Object[] { this.head, headSha, this.base, baseSha }); setHead(headSha); setBase(baseSha); if (accepted) { shouldRun = true; } return true; } private void checkComment(GHIssueComment comment) throws IOException { GHUser sender = comment.getUser(); String body = comment.getBody(); logger.log(Level.FINEST, "[{0}] Added comment: {1}", new Object[] { sender.getName(), body }); // Disabled until more advanced configs get set up // ignore comments from bot user, this fixes an issue where the bot would auto-whitelist // a user or trigger a build when the 'request for testing' phrase contains the // whitelist/trigger phrase and the bot is a member of a whitelisted organisation // if (helper.isBotUser(sender)) { // logger.log(Level.INFO, "Comment from bot user {0} ignored.", sender); // return; // } if (helper.isWhitelistPhrase(body) && helper.isAdmin(sender)) { // add to whitelist GHIssue parent = comment.getParent(); GHUser author = parent.getUser(); if (!helper.isWhitelisted(author)) { logger.log(Level.FINEST, "Author {0} not whitelisted, adding to whitelist.", author); helper.addWhitelist(author.getLogin()); } setAccepted(true); } else if (helper.isOktotestPhrase(body) && helper.isAdmin(sender)) { // ok to test logger.log(Level.FINEST, "Admin {0} gave OK to test", sender); setAccepted(true); } else if (helper.isRetestPhrase(body)) { // test this please logger.log(Level.FINEST, "Retest phrase"); if (helper.isAdmin(sender)) { logger.log(Level.FINEST, "Admin {0} gave retest phrase", sender); shouldRun = true; } else if (accepted && helper.isWhitelisted(sender)) { logger.log(Level.FINEST, "Retest accepted and user {0} is whitelisted", sender); shouldRun = true; } } else if (helper.isTriggerPhrase(body)) { // trigger phrase logger.log(Level.FINEST, "Trigger phrase"); if (helper.isAdmin(sender)) { logger.log(Level.FINEST, "Admin {0} ran trigger phrase", sender); shouldRun = true; triggered = true; } else if (accepted && helper.isWhitelisted(sender)) { logger.log(Level.FINEST, "Trigger accepted and user {0} is whitelisted", sender); shouldRun = true; triggered = true; } } if (shouldRun) { triggerSender = sender; commentBody = body; } } private int checkComments(GHPullRequest ghpr, Date lastUpdatedTime) { // It looks like this is always returning 0, ignoring till it can be confirmed. // if (ghpr.getCommentsCount() == 0) { // // Avoid the API call. Nothing to do here. // return 0; // } int count = 0; logger.log(Level.FINEST, "Checking for comments after: {0}", lastUpdatedTime); try { for (GHIssueComment comment : ghpr.getComments()) { logger.log(Level.FINEST, "Comment was made at: {0}", comment.getUpdatedAt()); if (lastUpdatedTime.compareTo(comment.getUpdatedAt()) < 0) { logger.log(Level.FINEST, "Comment was made after last update time, {0}", comment.getBody()); count++; try { checkComment(comment); } catch (IOException ex) { logger.log(Level.SEVERE, "Couldn't check comment #" + comment.getId(), ex); } } } } catch (IOException e) { logger.log(Level.SEVERE, "Couldn't obtain comments.", e); } return count; } public boolean checkMergeable() { try { int r = 5; Boolean isMergeable = pr.getMergeable(); while (isMergeable == null && r-- > 0) { try { Thread.sleep(1000); } catch (InterruptedException ex) { break; } // If the mergeability state was unknown, we need // to grab the mergeability state from the server. this.getPullRequest(true); isMergeable = pr.getMergeable(); } mergeable = isMergeable != null && isMergeable; } catch (IOException e) { logger.log(Level.SEVERE, "Couldn't obtain mergeable status.", e); } return mergeable; } @Override public boolean equals(Object obj) { if (!(obj instanceof GhprbPullRequest)) { return false; } GhprbPullRequest o = (GhprbPullRequest) obj; return o.id == id; } @Override public int hashCode() { int hash = 7; hash = 89 * hash + this.id; return hash; } public int getId() { return id; } public String getHead() { return head; } public String getAuthorRepoGitUrl() { GHCommitPointer prHead = pr.getHead(); String authorRepoGitUrl = ""; if (prHead != null && prHead.getRepository() != null) { authorRepoGitUrl = prHead.getRepository().gitHttpTransportUrl(); } return authorRepoGitUrl; } public boolean isMergeable() { return mergeable; } /** * Base and Ref are part of the PullRequest object * * @return the sha to the base */ public String getTarget() { try { return getPullRequest().getBase().getRef(); } catch (IOException e) { return "UNKNOWN"; } } /** * Head and Ref are part of the PullRequest object * * @return the sha for the head. */ public String getSource() { try { return getPullRequest().getHead().getRef(); } catch (IOException e) { return "UNKNOWN"; } } /** * Title is part of the PullRequest object * * @return the title of the pull request. */ public String getTitle() { try { return getPullRequest().getTitle(); } catch (IOException e) { return "UNKNOWN"; } } /** * Returns the URL to the Github Pull Request. * This URL is part of the pull request object * * @return the Github Pull Request URL * @throws IOException If unable to connect to GitHub */ public URL getUrl() throws IOException { return getPullRequest().getHtmlUrl(); } /** * The description body is part of the PullRequest object * * @return the description from github */ public String getDescription() { try { return getPullRequest().getBody(); } catch (IOException e) { return "UNKNOWN"; } } public GitUser getCommitAuthor() { return commitAuthor; } /** * Author is part of the PullRequest Object * * @return The GitHub user that created the PR * @throws IOException Unable to connect to GitHub */ public GHUser getPullRequestAuthor() throws IOException { return getPullRequest().getUser(); } /** * Get the PullRequest object for this PR * * @return a copy of the pull request * @throws IOException if unable to connect to GitHub */ public GHPullRequest getPullRequest() throws IOException { return getPullRequest(false); } /** * Get the PullRequest object for this PR * * @param force If true, forces retrieval of the PR info from the github API. Use sparingly. * @return a copy of the pull request * @throws IOException if unable to connect to GitHub */ public GHPullRequest getPullRequest(boolean force) throws IOException { if (this.pr == null || force) { setPullRequest(repo.getActualPullRequest(this.id)); } return pr; } private void setPullRequest(GHPullRequest pr) { if (pr == null) { return; } synchronized (this) { this.pr = pr; try { if (updated == null) { setUpdated(pr.getCreatedAt()); } } catch (IOException e) { logger.log(Level.WARNING, "Unable to get date for new PR", e); setUpdated(new Date()); } if (StringUtils.isEmpty(this.head)) { GHCommitPointer prHead = pr.getHead(); setHead(prHead.getSha()); } if (StringUtils.isEmpty(this.base)) { GHCommitPointer prBase = pr.getBase(); setBase(prBase.getSha()); } } } /** * Email address is collected from GitHub as extra information, so lets cache it. * * @return The PR authors email address */ public String getAuthorEmail() { if (StringUtils.isEmpty(authorEmail)) { try { GHUser user = getPullRequestAuthor(); authorEmail = user.getEmail(); } catch (IOException e) { logger.log(Level.SEVERE, "Unable to fetch author info for " + id); } } authorEmail = StringUtils.isEmpty(authorEmail) ? "" : authorEmail; return authorEmail; } public void setBuild(Run<?, ?> build) { lastBuildId = build.getId(); } public String getLastBuildId() { return lastBuildId; } }