diff --git a/.github/workflows/schedule-monthly-PREV.yml b/.github/workflows/schedule-monthly-PREV.yml new file mode 100644 index 0000000000..0ce2adefbb --- /dev/null +++ b/.github/workflows/schedule-monthly-PREV.yml @@ -0,0 +1,45 @@ +name: Schedule Monthly PREVIOUS + +on: + schedule: + - cron: "0 8 1 * *" + +jobs: + list-inactive-members: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + # gets a list of website-write team members with no open issues, returns a list of member's github handles + - name: Get List + uses: actions/github-script@v6 + id: get-list + with: + github-token: ${{ secrets.HACKFORLA_BOT_PA_TOKEN }} + script: | + const script = require('./github-actions/trigger-schedule/list-inactive-members/get-list.js'); + const getList = script({g: github, c: context}); + return getList; + + # creates a new issue in hackforla/website repo with the list populated above. creates a project card linking to this issue + - name: Create New Issue + uses: actions/github-script@v6 + id: create-new-issue + with: + github-token: ${{ secrets.HACKFORLA_BOT_PA_TOKEN }} + script: | + const script = require('./github-actions/trigger-schedule/list-inactive-members/create-new-issue.js'); + const list = ${{ steps.get-list.outputs.result }}; + const createNewIssue = script({g: github, c: context}, list); + return createNewIssue; + + # comments on issue #2607, notifying leads that the above issue has been created + - name: Comment Issue + uses: actions/github-script@v6 + id: comment-issue + with: + github-token: ${{ secrets.HACKFORLA_BOT_PA_TOKEN }} + script: | + const script = require('./github-actions/trigger-schedule/list-inactive-members/comment-issue.js'); + const newIssueNumber = ${{ steps.create-new-issue.outputs.result }}; + script({g: github, c: context}, newIssueNumber); diff --git a/.github/workflows/schedule-monthly.yml b/.github/workflows/schedule-monthly.yml index 471dff1366..ad09da2b86 100644 --- a/.github/workflows/schedule-monthly.yml +++ b/.github/workflows/schedule-monthly.yml @@ -1,45 +1,36 @@ name: Schedule Monthly +# This action runs at 11:00 UTC/ 3:00 PDT on the first day of the month. on: schedule: - - cron: "0 8 1 * *" + - cron: 0 11 1 * * jobs: - list-inactive-members: + trim_contributors: runs-on: ubuntu-latest + if: github.repository == 'hackforla/website' + steps: - - uses: actions/checkout@v3 + # Checkout repo + - name: Checkout repository + uses: actions/checkout@v3 + with: + token: ${{ secrets.GITHUB_TOKEN }} - # gets a list of website-write team members with no open issues, returns a list of member's github handles - - name: Get List - uses: actions/github-script@v6 - id: get-list - with: - github-token: ${{ secrets.HACKFORLA_BOT_PA_TOKEN }} - script: | - const script = require('./github-actions/trigger-schedule/list-inactive-members/get-list.js'); - const getList = script({g: github, c: context}); - return getList; + # Setup node + - name: Setup node + uses: actions/setup-node@v3 + with: + node-version: 18 + cache: 'npm' - # creates a new issue in hackforla/website repo with the list populated above. creates a project card linking to this issue - - name: Create New Issue - uses: actions/github-script@v6 - id: create-new-issue - with: - github-token: ${{ secrets.HACKFORLA_BOT_PA_TOKEN }} - script: | - const script = require('./github-actions/trigger-schedule/list-inactive-members/create-new-issue.js'); - const list = ${{ steps.get-list.outputs.result }}; - const createNewIssue = script({g: github, c: context}, list); - return createNewIssue; + # Install dependencies to run js file + - name: Install npm dependencies + run: npm install + working-directory: ./github-actions/trigger-schedule/github-data - # comments on issue #2607, notifying leads that the above issue has been created - - name: Comment Issue - uses: actions/github-script@v6 - id: comment-issue - with: - github-token: ${{ secrets.HACKFORLA_BOT_PA_TOKEN }} - script: | - const script = require('./github-actions/trigger-schedule/list-inactive-members/comment-issue.js'); - const newIssueNumber = ${{ steps.create-new-issue.outputs.result }}; - script({g: github, c: context}, newIssueNumber); + # Run js file- check action logs for inactive members and removes from 'website-write' + - name: Trim Members + env: + token: ${{ secrets.HACKFORLA_BOT_PA_TOKEN }} + run: node github-actions/trigger-schedule/github-data/contributors-data.js diff --git a/.github/workflows/schedule-thu-1100.yml b/.github/workflows/schedule-thu-1100.yml deleted file mode 100644 index e9e2d8cd4c..0000000000 --- a/.github/workflows/schedule-thu-1100.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: Schedule Thursday 1100 - - -# Run this action at 11:00 (3 am Pacific) on the first Thursday of the month. -on: - schedule: - - cron: 0 11 * * 4 - -jobs: - # This workflow contains a single job called "trim_contirbutors" - trim_contributors: - runs-on: ubuntu-latest - - if: github.repository == 'hackforla/website' - - steps: - # get the day number of the current month - - name: Get current date - id: date - run: echo "::set-output name=date::$(date +\%d)" - - # checkout repo - - name: Checkout repository - uses: actions/checkout@v3 - with: - token: ${{ secrets.GITHUB_TOKEN }} - - # setup node - - name: Setup node - uses: actions/setup-node@v3 - with: - node-version: '14' - cache: 'npm' - - # install dependencies to run js file - - name: Install npm dependencies - run: npm install - working-directory: ./github-actions/trigger-schedule/trim-contributors - - # run js file if it is the first Thursday of the month - - name: Trim Members - env: - token: ${{ secrets.HACKFORLA_BOT_PA_TOKEN }} - if: ${{ steps.date.outputs.date <= 7 }} - run: node github-actions/github-data/contributors-data.js diff --git a/github-actions/trigger-schedule/github-data/contributors-data.js b/github-actions/trigger-schedule/github-data/contributors-data.js new file mode 100644 index 0000000000..e439130e0a --- /dev/null +++ b/github-actions/trigger-schedule/github-data/contributors-data.js @@ -0,0 +1,247 @@ +const { Octokit } = require("@octokit/rest"); +const trueContributorsMixin = require("true-github-contributors"); + +// Extend Octokit with new contributor endpoints and construct instance of class with Auth token +Object.assign(Octokit.prototype, trueContributorsMixin); +const octokit = new Octokit({ auth: process.env.token }); + +// Set variables to avoid hard-coding +const org = 'hackforla'; +const repo = 'website'; +const team = 'website-write'; + +// Set date limits: at one month, warn contributor that they are +// inactive, and at two months remove contributor from team(s) +let oneMonthAgo = new Date(); // oneMonthAgo instantiated with date of "today" +oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1); // then set oneMonthAgo from "today" +oneMonthAgo = oneMonthAgo.toISOString(); +let twoMonthsAgo = new Date(); // twoMonthsAgo instantiated with date of "today" +twoMonthsAgo.setMonth(twoMonthsAgo.getMonth() - 2); // then set twoMonthsAgo from "today" +twoMonthsAgo = twoMonthsAgo.toISOString(); + + +/** + * Main function, immediately invoked + */ +(async function main(){ + const [contributorsOneMonthAgo, contributorsTwoMonthsAgo] = await fetchContributors(); + console.log('-------------------------------------------------------'); + console.log('List of active contributors since' + ' ⏰ ' + oneMonthAgo.slice(0, 10) + ':'); + console.log(contributorsOneMonthAgo); + + const currentTeamMembers = await fetchTeamMembers(); + console.log('-------------------------------------------------------'); + console.log('Current members of ' + team + ':') + console.log(currentTeamMembers) + + const removedContributors = await removeInactiveMembers(currentTeamMembers, contributorsTwoMonthsAgo); + console.log('-------------------------------------------------------'); + console.log('Removed members from ' + team + ' inactive since ' + twoMonthsAgo.slice(0, 10) + ':'); + console.log(removedContributors); + + const updatedTeamMembers = await fetchTeamMembers(); + const notifiedContributors = await notifyInactiveMembers(updatedTeamMembers, contributorsOneMonthAgo); + console.log('-------------------------------------------------------'); + console.log('Notified members from ' + team + ' inactive since ' + oneMonthAgo.slice(0, 10) + ':'); + console.log(notifiedContributors); + +})(); + + + +/** + * Function to fetch list of contributors with comments/commits/issues since date + * @return {Object} [List of active contributors] + */ +async function fetchContributors(){ + let allContributorsSinceOneMonthAgo = {}; + let allContributorsSinceTwoMonthsAgo = {}; + + // Fetch all contributors with commit, comment, and issue contributions + const APIs = ['GET /repos/{owner}/{repo}/commits', 'GET /repos/{owner}/{repo}/issues/comments', 'GET /repos/{owner}/{repo}/issues']; + const dates = [oneMonthAgo, twoMonthsAgo] + + for (const date of dates){ + const allContributorsSince = {}; + for(const api of APIs){ + let pageNum = 1; + let result = []; + + // Since Github only allows to fetch 100 items per request, we need to 'flip' pages + while(true){ + // Fetch 100 items from page number (`pageNum`) + // `oneMonthAgo` is a variable defined on top of the file + const contributors = await octokit.request(api, { + owner: org, + repo: repo, + since: date, + per_page: 100, + page: pageNum + }) + + // If the API call returns an empty array, break out of loop- there is no additional data on that page. + // Else if data is returned, push it to `result` and increase the page number (`pageNum`) + if(!contributors.data.length){ + break; + } else { + result = result.concat(contributors.data); + pageNum++; + } + } + + // Once we have looked at all pages and collected all the data, we create key-value pairs + // of recent contributors and store them in `allContributorsSince` object + + // The data that comes back from APIs is stored differently, i.e. `author.login` + // vs `user.login`, all we want is to extract the username of a contributor + for(const contributorInfo of result){ + // check if username is stored in author.login + if(contributorInfo.author){ + allContributorsSince[contributorInfo.author.login] = true; + } else if(contributorInfo.user){ + allContributorsSince[contributorInfo.user.login] = true; + + // This check is done for "issues" API (3rd element in the APIs array). Sometimes a user who created + // an issue is not the same as the user assigned to that issue- we want to make sure that we count + // all assignees as active contributors as well. + if(contributorInfo.assignees && contributorInfo.assignees.length){ + contributorInfo.assignees.forEach(user => allContributorsSince[user.login] = true); + } + } else { + console.log('You should not be seeing this message...'); + } // END if...else + } // END for(const contributorInfo of result) + } // END for(const api of APIs) + if(date == oneMonthAgo){ + allContributorsSinceOneMonthAgo = allContributorsSince; + } else { + allContributorsSinceTwoMonthsAgo = allContributorsSince; + } + } // END for(date of dates) + return [allContributorsSinceOneMonthAgo, allContributorsSinceTwoMonthsAgo]; +} + + + +/** + * Function to return list of current team members + * @return {Array} [Current team members] + */ +async function fetchTeamMembers(){ + + let pageNum = 1; + let teamResults = []; + + while(true){ + // Fetch all members of team. Note: if total members exceed 100, we need to 'flip' pages + const teamMembers = await octokit.request('GET /orgs/{org}/teams/{team_slug}/members', { + org: org, + team_slug: team, + per_page: 100, + page: pageNum + }) + + // If the API call returns an empty array, break out of loop- there is no additional data on that page. + // Else if data is returned, push it to `result` and increase the page number (`pageNum`) + if(!teamMembers.data.length){ + break; + } else { + teamResults = teamResults.concat(teamMembers.data); + pageNum++; + } + } + const allMembers = {}; + for(const member of teamResults){ + allMembers[member.login] = true; + } + return allMembers; +} + + + +/** + * Function to return list of contributors that have been inactive since twoMonthsAgo + * @param {Object} allMembers [List of active team] + * @param {Object} recentContributors [List of active contributors] + * @return {Array} [removed members] + */ +async function removeInactiveMembers(currentTeamMembers, recentContributors){ + const removedMembers = []; + + // Loop over team members and remove them from the team if they are not in recentContributors + for(const username in currentTeamMembers){ + if (!recentContributors[username]){ + // Remove contributor from a team if they don't pass additional checks in `toRemove` function + if(await toRemove(username)){ + await octokit.request('DELETE /orgs/{org}/teams/{team_slug}/memberships/{username}', { + org: org, + team_slug: team, + username: username, + }) + removedMembers.push(username); + } + } + } + return removedMembers; +} + + + +/** + * Function to check if a member is set for removal + * @param {String} member [member's username] + * @return {Boolean} [true/false] + */ +async function toRemove(member){ + // collect user's repos and see if they recently joined hackforla/website; + // Note: user might have > 100 repos, the code below will need adjustment (see 'flip' pages); + const repos = await octokit.request('GET /users/{username}/repos', { + username: member, + per_page: 100 + }) + + // if a user recently cloned 'website' repo (within the last 30 days), they are + // not consider for removal as they are new; + for(const repository of repos.data){ + // if repo is recently cloned, return 'false' or member is not be removed; + if(repository.name === repo && repository.created_at > oneMonthAgo){ + return false; + } + } + + // get user's membership status + const userMembership = await octokit.request('GET /orgs/{org}/teams/{team_slug}/memberships/{username}', { + org: org, + team_slug: team, + username: member, + }) + + // if a user is the team's maintainer, return 'false'. We do not remove maintainers; + if(userMembership.data.role === 'maintainer') return false; + + // else this user is an inactive member of the team thus remove; + return true; +} + + + +/** + * Function to return list of contributors that have been inactive since oneMonthAgo + * @param {Object} teamMembers [List of team members] + * @param {Object} recentContributors [List of active contributors] + * @return {Array} [removed members] + */ +async function notifyInactiveMembers(updatedTeamMembers, recentContributors){ + const notifiedMembers = []; + + // Loop over team members and add to "notify" list if they are not in recentContributors + for(const username in updatedTeamMembers){ + if (!recentContributors[username]){ + // Remove contributor from a team if they don't pass additional checks in `toRemove` function + if(await toRemove(username)){ + notifiedMembers.push(username) + } + } + } + return notifiedMembers; +} diff --git a/github-actions/trigger-schedule/trim-contributors/contributors-data.js b/github-actions/trigger-schedule/trim-contributors/contributors-data.js deleted file mode 100644 index 5b5fd6b392..0000000000 --- a/github-actions/trigger-schedule/trim-contributors/contributors-data.js +++ /dev/null @@ -1,181 +0,0 @@ -const { Octokit } = require("@octokit/rest"); -const trueContributorsMixin = require("true-github-contributors"); - -// Extend Octokit with new contributor endpoints and construct instance of class with Auth token -Object.assign(Octokit.prototype, trueContributorsMixin); -const octokit = new Octokit({ auth: process.env.token }); - -// set variables to avoid hard-coding -const org = 'hackforla'; -const repo = 'website'; -const team = 'website-write'; - -// set a date limit for when to remove a contributor from the team -const today = new Date(); -let monthAgo = new Date(today.setMonth(today.getMonth() - 1)); -monthAgo = monthAgo.toISOString(); - - -(async function main(){ - const commentCommitIssueContributors = await fetchContributors(); - - console.log('-------------------------------------------------------') - console.log('List of active contributors since' + ' ⏰ ' + monthAgo.slice(0, 10) + ':'); - console.log(commentCommitIssueContributors); - - const removedContributors = await removeInactiveMembers(commentCommitIssueContributors); - - console.log('-------------------------------------------------------') - console.log('Removed members: ') - console.log(removedContributors); -})() - - -/** - * Function to fetch comment/commit/issue contributors since 'date' - * @return {Object} [List of active contributors] - */ -async function fetchContributors(){ - const allContributorsSince = {} - - // fetch commit, comment, issues contirbutors; - const APIs = ['GET /repos/{owner}/{repo}/commits', 'GET /repos/{owner}/{repo}/issues/comments', 'GET /repos/{owner}/{repo}/issues']; - - for(const api of APIs){ - let pageNum = 1; - let result = []; - - // since Github only allows to fetch 100 items per request, we need to 'flip' pages - while(true){ - // fetch 100 items from page number (pageNum) - // monthAgo is a variable defined on top of the file - const contributors = await octokit.request(api, { - owner: org, - repo: repo, - since: monthAgo, - per_page: 100, - page: pageNum - }) - - // as soon as we get an empty array from API call, it means there - // is no data on that page => break the loop - if(!contributors.data.length){ - break; - - // if we get data, we push it to 'result' and increase the page number (pageNum) - } else { - result = result.concat(contributors.data); - pageNum++; - } - } - - // once we looked at all pages and collected all the data, we create key-value pairs of - // recent contributors and store them in 'allContributorsSince' object - - // the data that comes back from APIs is stored differently e.g. - // 'author.login' vs 'user.login', all we want is to extract the username of a contributor - for(const contributorInfo of result){ - // check if username is stored in author.login - if(contributorInfo.author){ - allContributorsSince[contributorInfo.author.login] = true; - } else { - allContributorsSince[contributorInfo.user.login] = true; - - // this check is done for issues API (3rd element in the APIs array). Sometimes a user who created - // an issue is not the same who got assigned to that issue so we want to make sure that we count - // all assignees as active contributors as well. - if(contributorInfo.assignees && contributorInfo.assignees.length){ - contributorInfo.assignees.forEach(user => allContributorsSince[user.login] = true); - } - } - } - } - - return allContributorsSince; -} - - -/** - * Function to remove inactive members from a team - * @param {Object} recentContributors [List of active contributors] - * @return {Array} [removed members] - */ -async function removeInactiveMembers(recentContributors){ - const removedMembers = [] - - // fetch all team members. Note: now we know that the total of members is 83, once - // we have over 100 members, code below need adjustments (see 'flip' pages above); - const teamMembers = await octokit.request('GET /orgs/{org}/teams/{team_slug}/members', { - org: org, - team_slug: team, - per_page: 100 - }) - - const allMembers = {} - for(const members of teamMembers.data){ - allMembers[members.login] = true; - } - - console.log('-------------------------------------------------------') - console.log('Team Members of ' + team + ':') - console.log(allMembers) - - // loop over team members and remove them from the team if they are not in recentContributors - for(const member of teamMembers.data){ - const username = member.login - if (!recentContributors[username]){ - // Remove contributor from a team if they don't pass additional checks in 'toRemove' function - if(await toRemove(username)){ - // TODO Remove the commented out code after testing - - await octokit.request('DELETE /orgs/{org}/teams/{team_slug}/memberships/{username}', { - org: org, - team_slug: team, - username: username, - }) - - removedMembers.push(username) - } - } - } - return removedMembers; -} - -/** - * Function to check if a member is set for removal - * @param {String} member [member's username] - * @return {Boolean} [true/false] - */ -async function toRemove(member){ - // collect user's repos and see if they recently joined hackforla/website; - // Note: user might have > 100 repos, the code below will need adjustment (see 'flip' pages); - const repos = await octokit.request('GET /users/{username}/repos', { - username: member, - per_page: 100 - }) - - // if a user recently cloned 'website' repo (within the last 30 days), they are - // not consider for removal as they are new; - for(const repository of repos.data){ - // if repo is recently cloned, return 'false' or member is not be removed; - if(repository.name === repo && repository.created_at > monthAgo){ - return false; - } - } - - // get user's membership status - const userMembership = await octokit.request('GET /orgs/{org}/teams/{team_slug}/memberships/{username}', { - org: org, - team_slug: team, - username: member, - }) - - // if a user is the team's maintainer, return 'false'. We do not remove maintainers; - if(userMembership.data.role === 'maintainer') return false; - - // else this user is an inactive member of the team thus remove; - return true; -} - - -