| /* | |
| * Licensed to the Apache Software Foundation (ASF) under one | |
| * or more contributor license agreements. See the NOTICE file | |
| * distributed with this work for additional information | |
| * regarding copyright ownership. The ASF licenses this file | |
| * to you under the Apache License, Version 2.0 (the | |
| * "License"); you may not use this file except in compliance | |
| * with the License. You may obtain a copy of the License at | |
| * | |
| * http://www.apache.org/licenses/LICENSE-2.0 | |
| * | |
| * Unless required by applicable law or agreed to in writing, software | |
| * distributed under the License is distributed on an "AS IS" BASIS, | |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| * See the License for the specific language governing permissions and | |
| * limitations under the License. | |
| */ | |
| const exec = require("@actions/exec"); | |
| const github = require("./shared/githubUtils"); | |
| const commentStrings = require("./shared/commentStrings"); | |
| const { | |
| REPO_OWNER, | |
| REPO, | |
| BOT_NAME, | |
| PATH_TO_CONFIG_FILE, | |
| } = require("./shared/constants"); | |
| const { ReviewerConfig } = require("./shared/reviewerConfig"); | |
| async function getPullsFromLastThreeMonths(): Promise<any[]> { | |
| const cutoffDate = new Date(); | |
| cutoffDate.setMonth(cutoffDate.getMonth() - 3); | |
| console.log(`Getting PRs newer than ${cutoffDate}`); | |
| const githubClient = github.getGitHubClient(); | |
| let result = await githubClient.rest.pulls.list({ | |
| owner: REPO_OWNER, | |
| repo: REPO, | |
| state: "closed", | |
| per_page: 100, // max allowed | |
| }); | |
| let page = 2; | |
| let retries = 0; | |
| let pulls = result.data; | |
| while ( | |
| result.data.length > 0 && | |
| new Date(result.data[result.data.length - 1].created_at) > cutoffDate | |
| ) { | |
| if (retries === 0) { | |
| console.log(`Getting PRs, page: ${page}`); | |
| console.log( | |
| `Current oldest PR = ${new Date( | |
| result.data[result.data.length - 1].created_at | |
| )}` | |
| ); | |
| } | |
| try { | |
| result = await githubClient.rest.pulls.list({ | |
| owner: REPO_OWNER, | |
| repo: REPO, | |
| state: "closed", | |
| per_page: 100, // max allowed | |
| page: page, | |
| }); | |
| pulls = pulls.concat(result.data); | |
| page++; | |
| retries = 0; | |
| } catch (err) { | |
| if (retries >= 3) { | |
| throw err; | |
| } | |
| retries++; | |
| } | |
| } | |
| return pulls; | |
| } | |
| interface pullActivity { | |
| reviews: number; | |
| pullsAuthored: number; | |
| } | |
| interface reviewerActivity { | |
| reviewers: { [reviewer: string]: pullActivity }; | |
| } | |
| async function getReviewersForPull(pull: any): Promise<string[]> { | |
| let reviewers = new Set<string>(); | |
| const githubClient = github.getGitHubClient(); | |
| let comments = ( | |
| await githubClient.rest.issues.listComments({ | |
| owner: REPO_OWNER, | |
| repo: REPO, | |
| issue_number: pull.number, | |
| }) | |
| ).data; | |
| const reviewComments = ( | |
| await githubClient.rest.pulls.listReviewComments({ | |
| owner: REPO_OWNER, | |
| repo: REPO, | |
| pull_number: pull.number, | |
| }) | |
| ).data; | |
| comments = comments.concat(reviewComments); | |
| for (const comment of comments) { | |
| if ( | |
| comment.user && | |
| comment.user.login && | |
| comment.user.login !== pull.user.login && | |
| comment.user.login !== BOT_NAME | |
| ) { | |
| reviewers.add(comment.user.login.toLowerCase()); | |
| } | |
| } | |
| return [...reviewers]; | |
| } | |
| function addReviewerActivity( | |
| reviewerActivity: reviewerActivity, | |
| reviewers: string[], | |
| author: string | |
| ): reviewerActivity { | |
| if (!reviewerActivity) { | |
| reviewerActivity = { | |
| reviewers: {}, | |
| }; | |
| } | |
| if (author in reviewerActivity.reviewers) { | |
| reviewerActivity.reviewers[author].pullsAuthored++; | |
| } else { | |
| reviewerActivity.reviewers[author] = { | |
| reviews: 0, | |
| pullsAuthored: 1, | |
| }; | |
| } | |
| for (const reviewer of reviewers) { | |
| if (reviewer !== author) { | |
| if (reviewer in reviewerActivity.reviewers) { | |
| reviewerActivity.reviewers[reviewer].reviews++; | |
| } else { | |
| reviewerActivity.reviewers[reviewer] = { | |
| reviews: 1, | |
| pullsAuthored: 0, | |
| }; | |
| } | |
| } | |
| } | |
| return reviewerActivity; | |
| } | |
| async function getReviewerActivityByLabel( | |
| pulls: any[] | |
| ): Promise<{ [label: string]: reviewerActivity }> { | |
| let reviewerActivityByLabel: { [label: string]: reviewerActivity } = {}; | |
| for (const pull of pulls) { | |
| console.log(`Processing PR ${pull.number}`); | |
| const author = pull.user.login.toLowerCase(); | |
| if (author !== BOT_NAME) { | |
| const reviewers = await getReviewersForPull(pull); | |
| const labels = pull.labels; | |
| for (const label of labels) { | |
| const labelName = label.name.toLowerCase(); | |
| reviewerActivityByLabel[labelName] = addReviewerActivity( | |
| reviewerActivityByLabel[labelName], | |
| reviewers, | |
| author | |
| ); | |
| } | |
| } | |
| } | |
| return reviewerActivityByLabel; | |
| } | |
| interface configUpdates { | |
| reviewersAddedForLabels: { [reviewer: string]: string[] }; | |
| reviewersRemovedForLabels: { [reviewer: string]: string[] }; | |
| } | |
| function reviewerIsBot(reviewer: string): boolean { | |
| if ( | |
| ["codecov", "github-actions"].find( | |
| (bot) => reviewer.toLowerCase().indexOf(bot) != -1 | |
| ) | |
| ) { | |
| return true; | |
| } | |
| return false; | |
| } | |
| function updateReviewerConfig( | |
| reviewerActivityByLabel: { [label: string]: reviewerActivity }, | |
| reviewerConfig: typeof ReviewerConfig | |
| ): configUpdates { | |
| let updates: configUpdates = { | |
| reviewersAddedForLabels: {}, | |
| reviewersRemovedForLabels: {}, | |
| }; | |
| const currentReviewersForLabels = reviewerConfig.getReviewersForAllLabels(); | |
| for (const label of Object.keys(currentReviewersForLabels)) { | |
| // Remove any reviewers with no reviews or pulls created | |
| let reviewers = currentReviewersForLabels[label]; | |
| let updatedReviewers: string[] = []; | |
| const exclusionList = reviewerConfig.getExclusionListForLabel(label); | |
| for (const reviewer of reviewers) { | |
| if (reviewerActivityByLabel[label].reviewers[reviewer]) { | |
| updatedReviewers.push(reviewer); | |
| } else { | |
| if (reviewer in updates.reviewersRemovedForLabels) { | |
| updates.reviewersRemovedForLabels[reviewer].push(label); | |
| } else { | |
| updates.reviewersRemovedForLabels[reviewer] = [label]; | |
| } | |
| } | |
| } | |
| // Add any reviewers who have at least 5 combined reviews + pulls authored | |
| for (const reviewer of Object.keys( | |
| reviewerActivityByLabel[label].reviewers | |
| )) { | |
| const reviewerContributions = | |
| reviewerActivityByLabel[label].reviewers[reviewer].reviews + | |
| reviewerActivityByLabel[label].reviewers[reviewer].pullsAuthored; | |
| if ( | |
| reviewerContributions >= 5 && | |
| updatedReviewers.indexOf(reviewer) < 0 && | |
| exclusionList.indexOf(reviewer) < 0 && | |
| !reviewerIsBot(reviewer) | |
| ) { | |
| updatedReviewers.push(reviewer); | |
| if (reviewer in updates.reviewersAddedForLabels) { | |
| updates.reviewersAddedForLabels[reviewer].push(label); | |
| } else { | |
| updates.reviewersAddedForLabels[reviewer] = [label]; | |
| } | |
| } | |
| } | |
| console.log( | |
| `Updated reviewers for label ${label}: ${updatedReviewers.join(",")}` | |
| ); | |
| reviewerConfig.updateReviewerForLabel(label, updatedReviewers); | |
| } | |
| return updates; | |
| } | |
| async function openPull(updates: configUpdates) { | |
| const curDate = new Date(); | |
| const branch = `pr-bot-${ | |
| curDate.getMonth() + 1 | |
| }-${curDate.getDay()}-${curDate.getFullYear()}-${curDate.getHours()}-${curDate.getMinutes()}-${curDate.getSeconds()}`; | |
| await exec.exec(`git config user.email ${BOT_NAME}@github.com`); | |
| await exec.exec("git config pull.rebase false"); | |
| await exec.exec(`git checkout -b ${branch}`); | |
| await exec.exec(`git add ${PATH_TO_CONFIG_FILE}`); | |
| await exec.exec( | |
| `git commit -m "Updating reviewer config based on historical trends"` | |
| ); | |
| await exec.exec(`git push origin ${branch}`); | |
| const prBody = commentStrings.updateReviewerConfig( | |
| updates.reviewersAddedForLabels, | |
| updates.reviewersRemovedForLabels | |
| ); | |
| await github.getGitHubClient().rest.pulls.create({ | |
| owner: REPO_OWNER, | |
| repo: REPO, | |
| head: branch, | |
| base: "master", | |
| title: "Update reviewer config to reviewers with activity in repo areas", | |
| body: prBody, | |
| maintainer_can_modify: true, | |
| }); | |
| } | |
| async function updateReviewers() { | |
| const pulls = await getPullsFromLastThreeMonths(); | |
| console.log("Got all PRs, moving to the processing stage"); | |
| const reviewerActivityByLabel = await getReviewerActivityByLabel(pulls); | |
| console.log("Processed all PRs to determine reviewer activity"); | |
| let reviewerConfig = new ReviewerConfig(PATH_TO_CONFIG_FILE); | |
| const updates = updateReviewerConfig(reviewerActivityByLabel, reviewerConfig); | |
| if ( | |
| Object.keys(updates.reviewersAddedForLabels).length > 0 || | |
| Object.keys(updates.reviewersRemovedForLabels).length > 0 | |
| ) { | |
| console.log(`Suggested updates to ${PATH_TO_CONFIG_FILE}`); | |
| await openPull(updates); | |
| } else { | |
| console.log("No updates to suggest"); | |
| } | |
| } | |
| updateReviewers(); | |
| export {}; |