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

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