github.com/prebid/prebid-server@v0.275.0/.github/workflows/helpers/pull-request-utils.js (about)

     1  const synchronizeEvent = "synchronize",
     2    openedEvent = "opened",
     3    completedStatus = "completed",
     4    resultSize = 100
     5  
     6  class diffHelper {
     7    constructor(input) {
     8      this.owner = input.context.repo.owner
     9      this.repo = input.context.repo.repo
    10      this.github = input.github
    11      this.pullRequestNumber = input.context.payload.pull_request.number
    12      this.pullRequestEvent = input.event
    13      this.testName = input.testName
    14      this.fileNameFilter = !input.fileNameFilter ? () => true : input.fileNameFilter
    15      this.fileLineFilter = !input.fileLineFilter ? () => true : input.fileLineFilter
    16    }
    17  
    18    /*
    19      Checks whether the test defined by this.testName has been executed on the given commit
    20      @param {string} commit - commit SHA to check for test execution
    21      @returns {boolean} - returns true if the test has been executed on the commit, otherwise false
    22    */
    23    async #isTestExecutedOnCommit(commit) {
    24      const response = await this.github.rest.checks.listForRef({
    25        owner: this.owner,
    26        repo: this.repo,
    27        ref: commit,
    28      })
    29  
    30      return response.data.check_runs.some(
    31        ({ status, name }) => status === completedStatus && name === this.testName
    32      )
    33    }
    34  
    35    /*
    36      Retrieves the line numbers of added or updated lines in the provided files
    37      @param {Array} files - array of files containing their filename and patch
    38      @returns {Object} - object mapping filenames to arrays of line numbers indicating the added or updated lines
    39    */
    40    async #getDiffForFiles(files = []) {
    41      let diff = {}
    42      for (const { filename, patch } of files) {
    43        if (this.fileNameFilter(filename)) {
    44          const lines = patch.split("\n")
    45          if (lines.length === 1) {
    46            continue
    47          }
    48  
    49          let lineNumber
    50          for (const line of lines) {
    51            // Check if line is diff header
    52            //  example:
    53            //    @@ -1,3 +1,3 @@
    54            //    1    var a
    55            //    2
    56            //    3   - //test
    57            //    3   +var b
    58            // Here @@ -1,3 +1,3 @@ is diff header
    59            if (line.match(/@@\s.*?@@/) != null) {
    60              lineNumber = parseInt(line.match(/\+(\d+)/)[0])
    61              continue
    62            }
    63  
    64            // "-" prefix indicates line was deleted. So do not consider deleted line
    65            if (line.startsWith("-")) {
    66              continue
    67            }
    68  
    69            // "+"" prefix indicates line was added or updated. Include line number in diff details
    70            if (line.startsWith("+") && this.fileLineFilter(line)) {
    71              diff[filename] = diff[filename] || []
    72              diff[filename].push(lineNumber)
    73            }
    74            lineNumber++
    75          }
    76        }
    77      }
    78      return diff
    79    }
    80  
    81    /*
    82      Retrieves a list of commits that have not been checked by the test defined by this.testName
    83      @returns {Array} - array of commit SHAs that have not been checked by the test
    84    */
    85    async #getNonScannedCommits() {
    86      const { data } = await this.github.rest.pulls.listCommits({
    87        owner: this.owner,
    88        repo: this.repo,
    89        pull_number: this.pullRequestNumber,
    90        per_page: resultSize,
    91      })
    92      let nonScannedCommits = []
    93  
    94      // API returns commits in ascending order. Loop in reverse to quickly retrieve unchecked commits
    95      for (let i = data.length - 1; i >= 0; i--) {
    96        const { sha, parents } = data[i]
    97  
    98        // Commit can be merged master commit. Such commit have multiple parents
    99        // Do not consider such commit for building file diff
   100        if (parents.length > 1) {
   101          continue
   102        }
   103  
   104        const isTestExecuted = await this.#isTestExecutedOnCommit(sha)
   105        if (isTestExecuted) {
   106          // Remaining commits have been tested in previous scans. Therefore, do not need to be considered again
   107          break
   108        } else {
   109          nonScannedCommits.push(sha)
   110        }
   111      }
   112  
   113      // Reverse to return commits in ascending order. This is needed to build diff for commits in chronological order
   114      return nonScannedCommits.reverse()
   115    }
   116  
   117    /*
   118      Filters the commit diff to include only the files that are part of the PR diff
   119      @param {Array} commitDiff - array of line numbers representing lines added or updated in the commit
   120      @param {Array} prDiff - array of line numbers representing lines added or updated in the pull request
   121      @returns {Array} - filtered commit diff, including only the files that are part of the PR diff
   122    */
   123    async #filterCommitDiff(commitDiff = [], prDiff = []) {
   124      return commitDiff.filter((file) => prDiff.includes(file))
   125    }
   126  
   127    /*
   128      Builds the diff for the pull request, including both the changes in the pull request and the changes in non-scanned commits
   129      @returns {string} - json string representation of the pull request diff and the diff for non-scanned commits
   130    */
   131    async buildDiff() {
   132      const { data } = await this.github.rest.pulls.listFiles({
   133        owner: this.owner,
   134        repo: this.repo,
   135        pull_number: this.pullRequestNumber,
   136        per_page: resultSize,
   137      })
   138  
   139      const pullRequestDiff = await this.#getDiffForFiles(data)
   140  
   141      const nonScannedCommitsDiff =
   142        Object.keys(pullRequestDiff).length != 0 && this.pullRequestEvent === synchronizeEvent // The "synchronize" event implies that new commit are pushed after the pull request was opened
   143          ? await this.getNonScannedCommitDiff(pullRequestDiff)
   144          : {}
   145  
   146      const prDiffFiles = Object.keys(pullRequestDiff)
   147      const pullRequest = {
   148        hasChanges: prDiffFiles.length > 0,
   149        files: prDiffFiles.join(" "),
   150        diff: pullRequestDiff,
   151      }
   152      const uncheckedCommits = { diff: nonScannedCommitsDiff }
   153      return JSON.stringify({ pullRequest, uncheckedCommits })
   154    }
   155  
   156    /*
   157      Retrieves the diff for non-scanned commits by comparing their changes with the pull request diff
   158      @param {Object} pullRequestDiff - The diff of files in the pull request
   159      @returns {Object} - The diff of files in the non-scanned commits that are part of the pull request diff
   160     */
   161    async getNonScannedCommitDiff(pullRequestDiff) {
   162      let nonScannedCommitsDiff = {}
   163      // Retrieves list of commits that have not been scanned by the PR check
   164      const nonScannedCommits = await this.#getNonScannedCommits()
   165      for (const commit of nonScannedCommits) {
   166        const { data } = await this.github.rest.repos.getCommit({
   167          owner: this.owner,
   168          repo: this.repo,
   169          ref: commit,
   170        })
   171  
   172        const commitDiff = await this.#getDiffForFiles(data.files)
   173        const files = Object.keys(commitDiff)
   174        for (const file of files) {
   175          // Consider scenario where the changes made to a file in the initial commit are completely undone by subsequent commits
   176          // In such cases, the modifications from the initial commit should not be taken into account
   177          // If the changes were entirely removed, there should be no entry for the file in the pullRequestStats
   178          const filePRDiff = pullRequestDiff[file]
   179          if (!filePRDiff) {
   180            continue
   181          }
   182  
   183          // Consider scenario where changes made in the commit were partially removed or modified by subsequent commits
   184          // In such cases, include only those commit changes that are part of the pullRequestStats object
   185          // This ensures that only the changes that are reflected in the pull request are considered
   186          const changes = await this.#filterCommitDiff(commitDiff[file], filePRDiff)
   187  
   188          if (changes.length !== 0) {
   189            // Check if nonScannedCommitsDiff[file] exists, if not assign an empty array to it
   190            nonScannedCommitsDiff[file] = nonScannedCommitsDiff[file] || []
   191            // Combine the existing nonScannedCommitsDiff[file] array with the commit changes
   192            // Remove any duplicate elements using the Set data structure
   193            nonScannedCommitsDiff[file] = [
   194              ...new Set([...nonScannedCommitsDiff[file], ...changes]),
   195            ]
   196          }
   197        }
   198      }
   199      return nonScannedCommitsDiff
   200    }
   201  
   202    /*
   203      Retrieves a list of directories from GitHub pull request files
   204      @param {Function} directoryExtractor - The function used to extract the directory name from the filename
   205      @returns {Array} An array of unique directory names
   206    */
   207    async getDirectories(directoryExtractor = () => "") {
   208      const { data } = await this.github.rest.pulls.listFiles({
   209        owner: this.owner,
   210        repo: this.repo,
   211        pull_number: this.pullRequestNumber,
   212        per_page: resultSize,
   213      })
   214  
   215      const directories = []
   216      for (const { filename } of data) {
   217        const directory = directoryExtractor(filename)
   218        if (directory != "" && !directories.includes(directory)) {
   219          directories.push(directory)
   220        }
   221      }
   222      return directories
   223    }
   224  }
   225  
   226  class semgrepHelper {
   227    constructor(input) {
   228      this.owner = input.context.repo.owner
   229      this.repo = input.context.repo.repo
   230      this.github = input.github
   231  
   232      this.pullRequestNumber = input.context.payload.pull_request.number
   233      this.pullRequestEvent = input.event
   234  
   235      this.pullRequestDiff = input.diff.pullRequest.diff
   236      this.newCommitsDiff = input.diff.uncheckedCommits.diff
   237  
   238      this.semgrepErrors = []
   239      this.semgrepWarnings = []
   240      input.semgrepResult.forEach((res) => {
   241        res.severity === "High" ? this.semgrepErrors.push(res) : this.semgrepWarnings.push(res)
   242      })
   243  
   244      this.headSha = input.headSha
   245    }
   246  
   247    /*
   248      Retrieves the matching line number from the provided diff for a given file and range of lines
   249      @param {Object} range - object containing the file, start line, and end line to find a match
   250      @param {Object} diff - object containing file changes and corresponding line numbers
   251      @returns {number|null} - line number that matches the range within the diff, or null if no match is found
   252    */
   253    async #getMatchingLineFromDiff({ file, start, end }, diff) {
   254      const fileDiff = diff[file]
   255      if (!fileDiff) {
   256        return null
   257      }
   258      if (fileDiff.includes(start)) {
   259        return start
   260      }
   261      if (fileDiff.includes(end)) {
   262        return end
   263      }
   264      return null
   265    }
   266  
   267    /*
   268      Splits the semgrep results into different categories based on the scan
   269      @param {Array} semgrepResults - array of results reported by semgrep
   270      @returns {Object} - object containing the categorized semgrep results i.e results reported in previous scans and new results found in the current scan
   271    */
   272    async #splitSemgrepResultsByScan(semgrepResults = []) {
   273      const result = {
   274        nonDiff: [], // Errors or warnings found in files updated in pull request, but not part of sections that were modified in the pull request
   275        previous: [], // Errors or warnings found in previous semgrep scans
   276        current: [], // Errors or warnings found in current semgrep scan
   277      }
   278  
   279      for (const se of semgrepResults) {
   280        const prDiffLine = await this.#getMatchingLineFromDiff(se, this.pullRequestDiff)
   281        if (!prDiffLine) {
   282          result.nonDiff.push({ ...se })
   283          continue
   284        }
   285  
   286        switch (this.pullRequestEvent) {
   287          case openedEvent:
   288            // "Opened" event implies that this is the first check
   289            // Therefore, the error should be appended to the result.current
   290            result.current.push({ ...se, line: prDiffLine })
   291          case synchronizeEvent:
   292            const commitDiffLine = await this.#getMatchingLineFromDiff(se, this.newCommitsDiff)
   293            // Check if error or warning is part of current commit diff
   294            // If not then error or warning was reported in previous scans
   295            commitDiffLine != null
   296              ? result.current.push({ ...se, line: commitDiffLine })
   297              : result.previous.push({
   298                  ...se,
   299                  line: prDiffLine,
   300                })
   301        }
   302      }
   303      return result
   304    }
   305  
   306    /*
   307      Adds review comments based on the semgrep results to the current pull request
   308      @returns {Object} - object containing the count of unaddressed comments from the previous scan and the count of new comments from the current scan
   309    */
   310    async addReviewComments() {
   311      let result = {
   312        previousScan: { unAddressedComments: 0 },
   313        currentScan: { newComments: 0 },
   314      }
   315  
   316      if (this.semgrepErrors.length == 0 && this.semgrepWarnings.length == 0) {
   317        return result
   318      }
   319  
   320      const errors = await this.#splitSemgrepResultsByScan(this.semgrepErrors)
   321      if (errors.previous.length == 0 && errors.current.length == 0) {
   322        console.log("Semgrep did not find any errors in the current pull request changes")
   323      } else {
   324        for (const { message, file, line } of errors.current) {
   325          await this.github.rest.pulls.createReviewComment({
   326            owner: this.owner,
   327            repo: this.repo,
   328            pull_number: this.pullRequestNumber,
   329            commit_id: this.headSha,
   330            body: message,
   331            path: file,
   332            line: line,
   333          })
   334        }
   335        result.currentScan.newComments = errors.current.length
   336        if (this.pullRequestEvent == synchronizeEvent) {
   337          result.previousScan.unAddressedComments = errors.previous.length
   338        }
   339      }
   340  
   341      const warnings = await this.#splitSemgrepResultsByScan(this.semgrepWarnings)
   342      for (const { message, file, line } of warnings.current) {
   343        await this.github.rest.pulls.createReviewComment({
   344          owner: this.owner,
   345          repo: this.repo,
   346          pull_number: this.pullRequestNumber,
   347          commit_id: this.headSha,
   348          body: "Consider this as a suggestion. " + message,
   349          path: file,
   350          line: line,
   351        })
   352      }
   353      return result
   354    }
   355  }
   356  
   357  class coverageHelper {
   358    constructor(input) {
   359      this.owner = input.context.repo.owner
   360      this.repo = input.context.repo.repo
   361      this.github = input.github
   362      this.pullRequestNumber = input.context.payload.pull_request.number
   363      this.headSha = input.headSha
   364      this.previewBaseURL = `https://htmlpreview.github.io/?https://github.com/${this.owner}/${this.repo}/coverage-preview/${input.remoteCoverageDir}`
   365      this.tmpCoverDir = input.tmpCoverageDir
   366    }
   367  
   368    /*
   369      Adds a code coverage summary along with heatmap links and coverage data on pull request as comment
   370      @param {Array} directories - directory for which coverage summary will be added
   371     */
   372    async AddCoverageSummary(directories = []) {
   373      const fs = require("fs")
   374      const path = require("path")
   375      const { promisify } = require("util")
   376      const readFileAsync = promisify(fs.readFile)
   377  
   378      let body = "## Code coverage summary \n"
   379      body += "Note: \n"
   380      body +=
   381        "- Prebid team doesn't anticipate tests covering code paths that might result in marshal and unmarshal errors \n"
   382      body += `- Coverage summary encompasses all commits leading up to the latest one, ${this.headSha} \n`
   383  
   384      for (const directory of directories) {
   385        let url = `${this.previewBaseURL}/${directory}.html`
   386        try {
   387          const textFilePath = path.join(this.tmpCoverDir, `${directory}.txt`)
   388          const data = await readFileAsync(textFilePath, "utf8")
   389  
   390          body += `#### ${directory} \n`
   391          body += `Refer [here](${url}) for heat map coverage report \n`
   392          body += "\`\`\` \n"
   393          body += data
   394          body += "\n \`\`\` \n"
   395        } catch (err) {
   396          console.error(err)
   397          return
   398        }
   399      }
   400  
   401      await this.github.rest.issues.createComment({
   402        owner: this.owner,
   403        repo: this.repo,
   404        issue_number: this.pullRequestNumber,
   405        body: body,
   406      })
   407    }
   408  }
   409  
   410  module.exports = {
   411    diffHelper: (input) => new diffHelper(input),
   412    semgrepHelper: (input) => new semgrepHelper(input),
   413    coverageHelper: (input) => new coverageHelper(input),
   414  }