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 }