Search code examples
jenkinsgithubmultibranch-pipeline

How to build only approved pull requests using the multi-branch plugin


I use the multibranch plugin to scan new pull requests, and I need to limit my builds to approved pull requests only. I filter the approved pull requests after they are scanned, however the repository scanner can only tell whether a pull request has new commits.

I tried the the PR comment build plugin and added the Trigger build on pull request review property to the github branch source to no avail - it seems that adding this property doesn't have any effect on how the scanner processes pull requests.

Can I tell the repository scanner to trigger a build on new reviews? Is there any other way to build pull requests only after approval?

Thanks!


Solution

  • I had to accept that there was no way to make the branch scan consider non-git objects (such as github reviews).

    I ended up making a pipeline that kept a list of all the yet-to-be-approved pull requests (using the github API), and once a pull request was approved it would trigger a build on it.

    It feels hacky, but unfortunately that was the only way I could think of to build only on approval... Important note: this solution requires an existing multibranch job to work with. So this is what I did:

    First query for the existing pull requests and their status (install the httpRequest plugin):

    // Send a query to github and get the response JSON
    def githubQuery(Map args = [:]) {
      def formattedQuery = args.query.replaceAll('\n', ' ').replaceAll('"', '\\\\"')
      def response = httpRequest(
        authentication: args.auth,
        httpMode: 'POST',
        requestBody: """{ "query": "${formattedQuery}" }""",
        url: "https://api.github.com/graphql"
      )
      (response.status == 200) ? readJSON(text: response.content) : [:]
    }
    
    def getPRStatus(Map args = [:]) {
      // Build the query to get all open pull requests and their status
      def query = """query {
        organization(login: "${args.organization}") {
          repositories(first: 30) {
            nodes {
              name
              pullRequests(first: 100, states: [OPEN]) {
                nodes {
                  number
                  state
                  reviews(first: 10, states: [APPROVED]) {
                    totalCount
                  }
                }
              }
            }
          }
        }
      }"""
      def response = githubQuery(args + [query: query])
      def repositories = response?.data.organization.repositories.nodes
      // Organize the pull requests into approved and unapproved groups
      repositories?.collectEntries { repo ->
        // Take out draft pull requests
        def prs = repo.pullRequests.nodes.findAll { it.state != "DRAFT" }
        def repoPrs = [
          unapproved: prs.findAll { it.reviews.totalCount == 0 },
          approved: prs.findAll { it.reviews.totalCount > 0 }
        ].collectEntries { category, categoryPrs ->
          [ category, categoryPrs.collect { it.number } ]
        }
        [ repo.name, repoPrs ]
      }
    }
    

    Then compare each pull request's status to its status from the previous poll, and build only those that changed their status to approved:

    def monitorRecentlyApprovedPRs(Map args = [:]) {
      def prMap = getPRStatus(args)
      // Build recently approved pull requests on each repository
      prMap.each { repoName, repoPrs ->
        // Get previously unapproved pull requests
        def previouslyUnapproved = currentBuild.previousBuild?.buildVariables?."${repoName}"?.tokenize(",").collect { it.toInteger() } ?: []
        // Build recently approved pull requests
        repoPrs.approved.intersect(previouslyUnapproved).each { prNumber ->
          build job: "/${args.multibranch}/PR-${prNumber}", wait: false
        }
        env."${repoName}" = repoPrs.unapproved.join(",")
      }
    }
    

    When calling monitorRecentlyApprovedPRs you'll have to provide these arguments:

    monitorRecentlyApprovedPRs organization: "YOUR-ORGANIZATION", auth: "GITHUB-CREDENTIALS", multibranch: "PATH-TO-THE-MULTIBRANCH-JOB-IN-JENKINS"
    

    Finally, update the multibranch's Jenkinsfile to skip unapproved PRs:

    def shouldBuildPR(Map args = [:]) {
      // Get pull request info
      def query = """query {
        organization(login: "${args.organization}") {
          repository(name: "${args.repo}") {
            pullRequest(number: ${args.pr}) {
              state
              reviews(first: 10, states: [APPROVED]) {
                totalCount
              }
            }
          }
        }
      }"""
      def response = githubQuery(args + [query: query])
      def prInfo = response?.data.organization.repository.pullRequest
      def shouldBuild = (
        // Skip merged pull requests
        prInfo.state != "MERGED" &&
        // Check for draft state
        (prInfo.state != "DRAFT") &&
        // Check for approval
        (prInfo.reviews.totalCount > 0)
      )
      shouldBuild
    }
    

    To call shouldBuildPR you'll provide these arguments:

    shouldBuildPR(organization: "YOUR-ORGANIZATION", repo: "PR-REPO", auth: "GITHUB-CREDENTIALS", pr: env.CHANGE_ID)
    

    If the returned value is false you should stop the rest of the pipeline's execution. Things would have been a lot simpler if the multibranch pipeline plugin provided a PR status environment variable :)