diff --git a/.env.example b/.env.example index 8e4a2a0..e5487b1 100644 --- a/.env.example +++ b/.env.example @@ -17,3 +17,5 @@ SLACK_USER_TOKEN= # Bot configuration (optional) # # DRY_RUN_BOUNTY_APPROVAL=true + +JENKINS_URL=https://:@jenkins.example.com \ No newline at end of file diff --git a/.github/github-bot.yml b/.github/github-bot.yml index bb5c18c..c3d34b6 100644 --- a/.github/github-bot.yml +++ b/.github/github-bot.yml @@ -12,6 +12,10 @@ bounty-project-board: welcome-bot: message: 'Thanks for making your first PR here!' +automated-tests: + repo-full-name: 'status-im/status-react' + job-full-name: 'end-to-end-tests/status-app-end-to-end-tests' + slack: notification: room: 'status-probot' diff --git a/README.md b/README.md index c3dcc18..23808c7 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,7 @@ available, etc! ## What does the bot do? -Right now the bot has two sets of capabilities: - -- Doing background management in GitHub: +- Background management in GitHub: - Assign new PRs to the `Pipeline for QA` project board (`REVIEW` column). - Move existing PRs to the correct `Pipeline for QA` project board column (`REVIEW`/`IN TEST`) depending on whether or not the required conditions are met (is mergeable, at least two reviewers have approved and there is no request for changes). - Assign issues that are labeled `bounty-awaiting-approval` to the `Status SOB Swarm` project board (`bounty-awaiting-approval` column). @@ -22,6 +20,7 @@ Right now the bot has two sets of capabilities: - Unfurls links on Issues and Pull Request discussions. - Disallows merging of PRs containing WIP in the title. - Mention repo collaborators on Slack when a GHI is assigned the `bounty-awaiting-approval` label. + - When a PR is moved to the IN TEST column and the build has passed successfully, then the bot will kick a test automation build in Jenkins (retrying periodically if the PR build is still running). - New functionality will be added in the future (wishlist is being tracked [here](https://docs.google.com/document/d/19NZEJ453av-owAEBXcIPjavbGKMBFlfVcwsuQ_ORzR4/)) The project board names, column names, welcome message and other values are stored in the `.github/github-bot.yml` file. It can be overriden for each specific repository by adding a file in the same path on the respective repository (see [probot-config](https://github.com/getsentry/probot-config)). @@ -74,11 +73,12 @@ See the official [docs for deployment](https://probot.github.io/docs/deployment/ - [x] Check the box for **Push** events - Repository projects - **Read & Write** - [x] Check the box for **Project for repository projects** events + - [x] Check the box for **Project card for repository projects** events - Organization projects - **Read-only** - [x] Check the box for **Project for organization projects** events - Single File - **Read-only** - Path: `.github/github-bot.yml` - 1. 🔍 Verify that you have **ticked 8 boxes**. + 1. 🔍 Verify that you have **ticked 9 boxes**. 1. Generate a private key pass and save it. 1. Installing the bot service: 1. Deploy the bot to the cloud. diff --git a/bot_scripts/assign-approved-pr-to-test.js b/bot_scripts/assign-approved-pr-to-test.js index bbedf2e..75c4121 100644 --- a/bot_scripts/assign-approved-pr-to-test.js +++ b/bot_scripts/assign-approved-pr-to-test.js @@ -13,6 +13,7 @@ // const getConfig = require('probot-config') const defaultConfig = require('../lib/config') +const gitHubHelpers = require('../lib/github-helpers') const createScheduler = require('probot-scheduler') const Slack = require('probot-slack-status') const slackHelper = require('../lib/slack') @@ -40,13 +41,6 @@ async function getProjectFromName (github, ownerName, repoName, projectBoardName return ghprojectsPayload.data.find(p => p.name === projectBoardName) } -async function getProjectCardForPullRequest (github, columnId, pullRequestUrl) { - const ghcardsPayload = await github.projects.getProjectCards({column_id: columnId}) - const ghcard = ghcardsPayload.data.find(c => c.content_url === pullRequestUrl) - - return ghcard -} - async function checkOpenPullRequests (robot, context) { const github = context.github const repo = context.payload.repository @@ -133,76 +127,16 @@ async function checkOpenPullRequests (robot, context) { } } -async function getPullRequestReviewStates (github, repo, pullRequest) { - var finalReviewsMap = new Map() - const ghreviews = await github.paginate( - github.pullRequests.getReviews({owner: repo.owner.login, repo: repo.name, number: pullRequest.number, per_page: 100}), - res => res.data) - for (var review of ghreviews) { - switch (review.state) { - case 'APPROVED': - case 'CHANGES_REQUESTED': - case 'PENDING': - finalReviewsMap.set(review.user.id, review.state) - break - } - } - - return Array.from(finalReviewsMap.values()) -} - -async function getReviewApprovalState (github, robot, repo, pullRequest) { - // Get detailed pull request - const fullPullRequestPayload = await github.pullRequests.get({owner: repo.owner.login, repo: repo.name, number: pullRequest.number}) - pullRequest = fullPullRequestPayload.data - if (pullRequest.mergeable !== null && pullRequest.mergeable !== undefined && !pullRequest.mergeable) { - robot.log.debug(`pullRequest.mergeable is ${pullRequest.mergeable}, considering as failed`) - return 'failed' - } - - let state - switch (pullRequest.mergeable_state) { - case 'clean': - state = 'approved' - break - case 'dirty': - state = 'failed' - break - } - robot.log.debug(`pullRequest.mergeable_state is ${pullRequest.mergeable_state}, considering state as ${state}`) - - if (state !== 'approved') { - return state - } - - const threshold = 2 // Minimum number of approvers - - var finalReviews = await getPullRequestReviewStates(github, repo, pullRequest) - robot.log.debug(finalReviews) - - const approvedReviews = finalReviews.filter(reviewState => reviewState === 'APPROVED') - if (approvedReviews.length >= threshold) { - const reviewsWithChangesRequested = finalReviews.filter(reviewState => reviewState === 'CHANGES_REQUESTED') - if (reviewsWithChangesRequested.length === 0) { - return 'approved' - } - - return 'changes_requested' - } - - return 'awaiting_reviewers' -} - async function assignPullRequestToCorrectColumn (github, robot, repo, pullRequest, contributorColumn, reviewColumn, testColumn, room) { - const ownerName = repo.owner.login + const repoOwner = repo.owner.login const repoName = repo.name const prNumber = pullRequest.number let state = null try { - state = await getReviewApprovalState(github, robot, repo, pullRequest) + state = await gitHubHelpers.getReviewApprovalState(github, robot, repoOwner, repoName, prNumber) } catch (err) { - robot.log.error(`Couldn't calculate the PR approval state: ${err}`, ownerName, repoName, prNumber) + robot.log.error(`Couldn't calculate the PR approval state: ${err}`, repoOwner, repoName, prNumber) } let srcColumns, dstColumn @@ -227,14 +161,14 @@ async function assignPullRequestToCorrectColumn (github, robot, repo, pullReques return } - robot.log.debug(`assignPullRequestToTest - Handling Pull Request #${prNumber} on repo ${ownerName}/${repoName}. PR should be in ${dstColumn.name} column`) + robot.log.debug(`assignPullRequestToTest - Handling Pull Request #${prNumber} on repo ${repoOwner}/${repoName}. PR should be in ${dstColumn.name} column`) // Look for PR card in source column(s) let existingGHCard = null let srcColumn = null for (const c of srcColumns) { try { - existingGHCard = await getProjectCardForPullRequest(github, c.id, pullRequest.issue_url) + existingGHCard = await gitHubHelpers.getProjectCardForIssue(github, c.id, pullRequest.issue_url) if (existingGHCard) { srcColumn = c break @@ -274,7 +208,7 @@ async function assignPullRequestToCorrectColumn (github, robot, repo, pullReques // Look for PR card in destination column try { - const existingGHCard = await getProjectCardForPullRequest(github, dstColumn.id, pullRequest.issue_url) + const existingGHCard = await gitHubHelpers.getProjectCardForIssue(github, dstColumn.id, pullRequest.issue_url) if (existingGHCard) { robot.log.trace(`Found card in target column, ignoring`, existingGHCard.id, dstColumn.id) return diff --git a/bot_scripts/assign-to-bounty-awaiting-for-approval.js b/bot_scripts/assign-to-bounty-awaiting-for-approval.js index b9821d5..20aed8f 100644 --- a/bot_scripts/assign-to-bounty-awaiting-for-approval.js +++ b/bot_scripts/assign-to-bounty-awaiting-for-approval.js @@ -12,6 +12,7 @@ // const getConfig = require('probot-config') const slackHelper = require('../lib/slack') +const gitHubHelpers = require('../lib/github-helpers') const defaultConfig = require('../lib/config') const Slack = require('probot-slack-status') @@ -106,7 +107,6 @@ async function assignIssueToBountyAwaitingForApproval (context, robot, assign) { return } - let ghcardPayload = null if (process.env.DRY_RUN) { if (assign) { robot.log.info(`Would have created card for issue`, column.id, payload.issue.id) @@ -117,7 +117,7 @@ async function assignIssueToBountyAwaitingForApproval (context, robot, assign) { if (assign) { try { // Create project card for the issue in the bounty-awaiting-approval column - ghcardPayload = await github.projects.createProjectCard({ + const ghcardPayload = await github.projects.createProjectCard({ column_id: column.id, content_type: 'Issue', content_id: payload.issue.id @@ -130,7 +130,7 @@ async function assignIssueToBountyAwaitingForApproval (context, robot, assign) { } } else { try { - const ghcard = await getProjectCardForIssue(github, column.id, payload.issue.url) + const ghcard = await gitHubHelpers.getProjectCardForIssue(github, column.id, payload.issue.url) if (ghcard) { await github.projects.deleteProjectCard({id: ghcard.id}) robot.log(`Deleted card: ${ghcard.url}`, ghcard.id) @@ -150,10 +150,3 @@ async function assignIssueToBountyAwaitingForApproval (context, robot, assign) { } } } - -async function getProjectCardForIssue (github, columnId, issueUrl) { - const ghcardsPayload = await github.projects.getProjectCards({column_id: columnId}) - const ghcard = ghcardsPayload.data.find(c => c.content_url === issueUrl) - - return ghcard -} diff --git a/bot_scripts/trigger-automation-test-build.js b/bot_scripts/trigger-automation-test-build.js new file mode 100644 index 0000000..6995a79 --- /dev/null +++ b/bot_scripts/trigger-automation-test-build.js @@ -0,0 +1,158 @@ +// Description: +// Script that listens for PRs moving into the 'TO TEST' column +// and triggers a Jenkins build. +// +// Dependencies: +// github: "^13.1.0" +// jenkins: "^0.20.1" +// probot-config: "^0.1.0" +// +// Author: +// PombeirP + +const defaultConfig = require('../lib/config') +const gitHubHelpers = require('../lib/github-helpers') +const jenkins = require('jenkins')({ baseUrl: process.env.JENKINS_URL, crumbIssuer: true, promisify: true }) +const HashMap = require('hashmap') + +const pendingPullRequests = new HashMap() + +module.exports = (robot) => { + const config = defaultConfig(robot, '.github/github-bot.yml') + const projectBoardConfig = config['project-board'] + const automatedTestsConfig = config['automated-tests'] + + if (!process.env.JENKINS_URL) { + robot.log.info('trigger-automation-test-build - Jenkins is not configured, not loading script') + return + } + + if (projectBoardConfig && automatedTestsConfig) { + setInterval(checkPendingPullRequests, 5 * 1000 * 60, robot) + registerForRelevantCardEvents(robot, { projectBoardConfig: projectBoardConfig, automatedTestingConfig: automatedTestsConfig }) + } +} + +function registerForRelevantCardEvents (robot, config) { + robot.on('project_card.created', context => processChangedProjectCard(robot, context, config)) + robot.on('project_card.moved', context => processChangedProjectCard(robot, context, config)) +} + +async function processChangedProjectCard (robot, context, config) { + const { github, payload } = context + + if (payload.project_card.note) { + robot.log.trace(`trigger-automation-test-build - Card is a note, ignoring`) + return + } + + const { projectBoardConfig, automatedTestingConfig } = config + const projectBoardName = projectBoardConfig['name'] + const testColumnName = projectBoardConfig['test-column-name'] + const repo = payload.repository + + if (repo.full_name !== automatedTestingConfig['repo-full-name']) { + robot.log.trace(`trigger-automation-test-build - Pull request project doesn't match watched repo, exiting`, repo.full_name, automatedTestingConfig['repo-full-name']) + return + } + + let inTestColumn + try { + const columnPayload = await github.projects.getProjectColumn({ id: payload.project_card.column_id }) + + if (columnPayload.data.name !== testColumnName) { + robot.log.trace(`trigger-automation-test-build - Card column name doesn't match watched column name, exiting`, columnPayload.data.name, testColumnName) + return + } + + inTestColumn = columnPayload.data + } catch (error) { + robot.log.warn(`trigger-automation-test-build - Error while fetching project column`, payload.project_card.column_id, error) + return + } + + const last = (a, index) => { + return a[a.length + index] + } + + let project + try { + const projectId = last(inTestColumn.project_url.split('/'), -1) + const projectPayload = await github.projects.getProject({ id: projectId }) + + project = projectPayload.data + if (project.name !== projectBoardName) { + robot.log.trace(`trigger-automation-test-build - Card column name doesn't match watched column name, exiting`, project.name, projectBoardName) + return + } + } catch (error) { + robot.log.warn(`trigger-automation-test-build - Error while fetching project column`, payload.project_card.column_id, error) + return + } + + const prNumber = last(payload.project_card.content_url.split('/'), -1) + const fullJobName = automatedTestingConfig['job-full-name'] + + await processPullRequest(github, robot, repo.owner.login, repo.name, prNumber, fullJobName) +} + +async function processPullRequest (github, robot, repoOwner, repoName, prNumber, fullJobName) { + // Remove the PR from the pending PR list, if it is there + pendingPullRequests.delete(prNumber) + + try { + const state = await gitHubHelpers.getReviewApprovalState(github, robot, repoOwner, repoName, prNumber) + + switch (state) { + case 'unstable': + case 'awaiting_reviewers': + case 'changes_requested': + pendingPullRequests.set(prNumber, { github: github, repoOwner: repoOwner, repoName: repoName, fullJobName: fullJobName }) + robot.log.debug(`trigger-automation-test-build - State is '${state}', adding to backlog to check periodically`, prNumber) + return + case 'failed': + robot.log.debug(`trigger-automation-test-build - State is '${state}', exiting`, prNumber) + return + case 'approved': + robot.log.debug(`trigger-automation-test-build - State is '${state}', proceeding`, prNumber) + break + default: + robot.log.warn(`trigger-automation-test-build - State is '${state}', ignoring`, prNumber) + return + } + } catch (err) { + robot.log.error(`Couldn't calculate the PR approval state: ${err}`, repoOwner, repoName, prNumber) + return + } + + try { + const args = { parameters: { pr_id: prNumber, apk: `--apk=${prNumber}.apk` } } + + if (process.env.DRY_RUN) { + robot.log(`trigger-automation-test-build - Would start ${fullJobName} job in Jenkins`, prNumber, args) + } else { + robot.log(`trigger-automation-test-build - Starting ${fullJobName} job in Jenkins`, prNumber, args) + const buildId = await jenkins.job.build(fullJobName, args) + robot.log(`trigger-automation-test-build - Started job in Jenkins`, prNumber, buildId) + } + } catch (error) { + robot.log.error(`trigger-automation-test-build - Error while triggering Jenkins build. Will retry later`, prNumber, error) + + pendingPullRequests.set(prNumber, { github: github, repoOwner: repoOwner, repoName: repoName, fullJobName: fullJobName }) + } +} + +async function checkPendingPullRequests (robot) { + const _pendingPullRequests = pendingPullRequests.clone() + + robot.log.trace(`trigger-automation-test-build - Processing ${_pendingPullRequests.size} pending PRs`) + + for (const kvp of _pendingPullRequests.entries()) { + const prNumber = kvp[0] + const { github, repoOwner, repoName, fullJobName } = kvp[1] + + await processPullRequest(github, robot, repoOwner, repoName, prNumber, fullJobName) + } + + robot.log.trace(`trigger-automation-test-build - Finished processing ${_pendingPullRequests.size} pending PRs`) +} diff --git a/index.js b/index.js index f56b414..5bdb6be 100644 --- a/index.js +++ b/index.js @@ -11,6 +11,7 @@ module.exports = async (robot) => { require('./bot_scripts/assign-approved-pr-to-test')(robot) require('./bot_scripts/assign-to-bounty-awaiting-for-approval')(robot) require('./bot_scripts/greet-new-contributor')(robot) + require('./bot_scripts/trigger-automation-test-build')(robot) await slackCachePromise robot.log.info('Slack user ID cache populated, loading remainder of scripts') diff --git a/lib/github-helpers.js b/lib/github-helpers.js new file mode 100644 index 0000000..f8393d6 --- /dev/null +++ b/lib/github-helpers.js @@ -0,0 +1,79 @@ +// Description: +// GtHub-related helpers +// +// Dependencies: +// github: "^13.1.0" +// +// Author: +// PombeirP + +module.exports.getPullRequestReviewStates = _getPullRequestReviewStates +module.exports.getReviewApprovalState = _getReviewApprovalState +module.exports.getProjectCardForIssue = _getProjectCardForIssue + +async function _getPullRequestReviewStates (github, repoOwner, repoName, prNumber) { + let finalReviewsMap = new Map() + const ghreviews = await github.paginate( + github.pullRequests.getReviews({owner: repoOwner, repo: repoName, number: prNumber, per_page: 100}), + res => res.data) + for (var review of ghreviews) { + switch (review.state) { + case 'APPROVED': + case 'CHANGES_REQUESTED': + case 'PENDING': + finalReviewsMap.set(review.user.id, review.state) + break + } + } + + return Array.from(finalReviewsMap.values()) +} + +async function _getReviewApprovalState (github, robot, repoOwner, repoName, prNumber) { + // Get detailed pull request + const pullRequestPayload = await github.pullRequests.get({owner: repoOwner, repo: repoName, number: prNumber}) + const pullRequest = pullRequestPayload.data + if (pullRequest.mergeable !== null && pullRequest.mergeable !== undefined && !pullRequest.mergeable) { + robot.log.debug(`pullRequest.mergeable is ${pullRequest.mergeable}, considering as failed`) + return 'failed' + } + + let state + switch (pullRequest.mergeable_state) { + case 'clean': + state = 'approved' + break + case 'dirty': + state = 'failed' + break + } + robot.log.debug(`pullRequest.mergeable_state is ${pullRequest.mergeable_state}, considering state as ${state}`) + + if (state !== 'approved') { + return state + } + + const threshold = 2 // Minimum number of approvers + + const finalReviews = await _getPullRequestReviewStates(github, repoOwner, repoName, pullRequest.number) + robot.log.debug(finalReviews) + + const approvedReviews = finalReviews.filter(reviewState => reviewState === 'APPROVED') + if (approvedReviews.length >= threshold) { + const reviewsWithChangesRequested = finalReviews.filter(reviewState => reviewState === 'CHANGES_REQUESTED') + if (reviewsWithChangesRequested.length === 0) { + return 'approved' + } + + return 'changes_requested' + } + + return 'awaiting_reviewers' +} + +async function _getProjectCardForIssue (github, columnId, issueUrl) { + const ghcardsPayload = await github.projects.getProjectCards({column_id: columnId}) + const ghcard = ghcardsPayload.data.find(c => c.content_url === issueUrl) + + return ghcard +} diff --git a/package-lock.json b/package-lock.json index 88a8029..4b1786d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2538,6 +2538,11 @@ "integrity": "sha1-uhqPGvKg/DllD1yFA2dwQSIGO0Q=", "dev": true }, + "hashmap": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/hashmap/-/hashmap-2.3.0.tgz", + "integrity": "sha1-sT+2XafIul49uPwbjFuh0ASdryI=" + }, "hawk": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/hawk/-/hawk-6.0.2.tgz", @@ -3103,6 +3108,14 @@ "handlebars": "4.0.5" } }, + "jenkins": { + "version": "0.20.1", + "resolved": "https://registry.npmjs.org/jenkins/-/jenkins-0.20.1.tgz", + "integrity": "sha512-4vpnBYIyy995FaReWP3LAGaVsQgV9WayI1pjEHbF+oIM/nV5DyGSwa/xIojZ7U+ECk8ZcXsCJDOhuJQvCr0AUA==", + "requires": { + "papi": "0.26.0" + } + }, "jest": { "version": "22.1.4", "resolved": "https://registry.npmjs.org/jest/-/jest-22.1.4.tgz", @@ -4285,6 +4298,11 @@ "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz", "integrity": "sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M=" }, + "papi": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/papi/-/papi-0.26.0.tgz", + "integrity": "sha1-1hNqFJIHXrwmRSvY4J5EmYDEfdM=" + }, "parse-glob": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/parse-glob/-/parse-glob-3.0.4.tgz", diff --git a/package.json b/package.json index d1225f1..9bd096f 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,8 @@ }, "dependencies": { "eslint": "^4.16.0", + "hashmap": "^2.3.0", + "jenkins": "^0.20.1", "mem-cache": "0.0.5", "probot": "^5.0.0", "probot-config": "^0.1.0",