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 }