Add script to trigger automated tests build when PR is moved to IN TEST column. Closes #15

This commit is contained in:
Pedro Pombeiro 2018-02-07 17:59:55 +01:00
parent 29ac60f3b3
commit 9ff69661a1
No known key found for this signature in database
GPG Key ID: A65DEB11E4BBC647
10 changed files with 278 additions and 87 deletions

View File

@ -17,3 +17,5 @@ SLACK_USER_TOKEN=
# Bot configuration (optional) # Bot configuration (optional)
# #
# DRY_RUN_BOUNTY_APPROVAL=true # DRY_RUN_BOUNTY_APPROVAL=true
JENKINS_URL=https://<user>:<password>@jenkins.example.com

View File

@ -12,6 +12,10 @@ bounty-project-board:
welcome-bot: welcome-bot:
message: 'Thanks for making your first PR here!' 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: slack:
notification: notification:
room: 'status-probot' room: 'status-probot'

View File

@ -11,9 +11,7 @@ available, etc!
## What does the bot do? ## What does the bot do?
Right now the bot has two sets of capabilities: - Background management in GitHub:
- Doing background management in GitHub:
- Assign new PRs to the `Pipeline for QA` project board (`REVIEW` column). - 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). - 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). - 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. - Unfurls links on Issues and Pull Request discussions.
- Disallows merging of PRs containing WIP in the title. - Disallows merging of PRs containing WIP in the title.
- Mention repo collaborators on Slack when a GHI is assigned the `bounty-awaiting-approval` label. - 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/)) - 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)). 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 - [x] Check the box for **Push** events
- Repository projects - **Read & Write** - Repository projects - **Read & Write**
- [x] Check the box for **Project for repository projects** events - [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** - Organization projects - **Read-only**
- [x] Check the box for **Project for organization projects** events - [x] Check the box for **Project for organization projects** events
- Single File - **Read-only** - Single File - **Read-only**
- Path: `.github/github-bot.yml` - 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. Generate a private key pass and save it.
1. Installing the bot service: 1. Installing the bot service:
1. Deploy the bot to the cloud. 1. Deploy the bot to the cloud.

View File

@ -13,6 +13,7 @@
// const getConfig = require('probot-config') // const getConfig = require('probot-config')
const defaultConfig = require('../lib/config') const defaultConfig = require('../lib/config')
const gitHubHelpers = require('../lib/github-helpers')
const createScheduler = require('probot-scheduler') const createScheduler = require('probot-scheduler')
const Slack = require('probot-slack-status') const Slack = require('probot-slack-status')
const slackHelper = require('../lib/slack') const slackHelper = require('../lib/slack')
@ -40,13 +41,6 @@ async function getProjectFromName (github, ownerName, repoName, projectBoardName
return ghprojectsPayload.data.find(p => p.name === 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) { async function checkOpenPullRequests (robot, context) {
const github = context.github const github = context.github
const repo = context.payload.repository 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) { 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 repoName = repo.name
const prNumber = pullRequest.number const prNumber = pullRequest.number
let state = null let state = null
try { try {
state = await getReviewApprovalState(github, robot, repo, pullRequest) state = await gitHubHelpers.getReviewApprovalState(github, robot, repoOwner, repoName, prNumber)
} catch (err) { } 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 let srcColumns, dstColumn
@ -227,14 +161,14 @@ async function assignPullRequestToCorrectColumn (github, robot, repo, pullReques
return 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) // Look for PR card in source column(s)
let existingGHCard = null let existingGHCard = null
let srcColumn = null let srcColumn = null
for (const c of srcColumns) { for (const c of srcColumns) {
try { try {
existingGHCard = await getProjectCardForPullRequest(github, c.id, pullRequest.issue_url) existingGHCard = await gitHubHelpers.getProjectCardForIssue(github, c.id, pullRequest.issue_url)
if (existingGHCard) { if (existingGHCard) {
srcColumn = c srcColumn = c
break break
@ -274,7 +208,7 @@ async function assignPullRequestToCorrectColumn (github, robot, repo, pullReques
// Look for PR card in destination column // Look for PR card in destination column
try { try {
const existingGHCard = await getProjectCardForPullRequest(github, dstColumn.id, pullRequest.issue_url) const existingGHCard = await gitHubHelpers.getProjectCardForIssue(github, dstColumn.id, pullRequest.issue_url)
if (existingGHCard) { if (existingGHCard) {
robot.log.trace(`Found card in target column, ignoring`, existingGHCard.id, dstColumn.id) robot.log.trace(`Found card in target column, ignoring`, existingGHCard.id, dstColumn.id)
return return

View File

@ -12,6 +12,7 @@
// const getConfig = require('probot-config') // const getConfig = require('probot-config')
const slackHelper = require('../lib/slack') const slackHelper = require('../lib/slack')
const gitHubHelpers = require('../lib/github-helpers')
const defaultConfig = require('../lib/config') const defaultConfig = require('../lib/config')
const Slack = require('probot-slack-status') const Slack = require('probot-slack-status')
@ -106,7 +107,6 @@ async function assignIssueToBountyAwaitingForApproval (context, robot, assign) {
return return
} }
let ghcardPayload = null
if (process.env.DRY_RUN) { if (process.env.DRY_RUN) {
if (assign) { if (assign) {
robot.log.info(`Would have created card for issue`, column.id, payload.issue.id) 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) { if (assign) {
try { try {
// Create project card for the issue in the bounty-awaiting-approval column // 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, column_id: column.id,
content_type: 'Issue', content_type: 'Issue',
content_id: payload.issue.id content_id: payload.issue.id
@ -130,7 +130,7 @@ async function assignIssueToBountyAwaitingForApproval (context, robot, assign) {
} }
} else { } else {
try { try {
const ghcard = await getProjectCardForIssue(github, column.id, payload.issue.url) const ghcard = await gitHubHelpers.getProjectCardForIssue(github, column.id, payload.issue.url)
if (ghcard) { if (ghcard) {
await github.projects.deleteProjectCard({id: ghcard.id}) await github.projects.deleteProjectCard({id: ghcard.id})
robot.log(`Deleted card: ${ghcard.url}`, 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
}

View File

@ -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`)
}

View File

@ -11,6 +11,7 @@ module.exports = async (robot) => {
require('./bot_scripts/assign-approved-pr-to-test')(robot) require('./bot_scripts/assign-approved-pr-to-test')(robot)
require('./bot_scripts/assign-to-bounty-awaiting-for-approval')(robot) require('./bot_scripts/assign-to-bounty-awaiting-for-approval')(robot)
require('./bot_scripts/greet-new-contributor')(robot) require('./bot_scripts/greet-new-contributor')(robot)
require('./bot_scripts/trigger-automation-test-build')(robot)
await slackCachePromise await slackCachePromise
robot.log.info('Slack user ID cache populated, loading remainder of scripts') robot.log.info('Slack user ID cache populated, loading remainder of scripts')

79
lib/github-helpers.js Normal file
View File

@ -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
}

18
package-lock.json generated
View File

@ -2538,6 +2538,11 @@
"integrity": "sha1-uhqPGvKg/DllD1yFA2dwQSIGO0Q=", "integrity": "sha1-uhqPGvKg/DllD1yFA2dwQSIGO0Q=",
"dev": true "dev": true
}, },
"hashmap": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/hashmap/-/hashmap-2.3.0.tgz",
"integrity": "sha1-sT+2XafIul49uPwbjFuh0ASdryI="
},
"hawk": { "hawk": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/hawk/-/hawk-6.0.2.tgz", "resolved": "https://registry.npmjs.org/hawk/-/hawk-6.0.2.tgz",
@ -3103,6 +3108,14 @@
"handlebars": "4.0.5" "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": { "jest": {
"version": "22.1.4", "version": "22.1.4",
"resolved": "https://registry.npmjs.org/jest/-/jest-22.1.4.tgz", "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", "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz",
"integrity": "sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M=" "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": { "parse-glob": {
"version": "3.0.4", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/parse-glob/-/parse-glob-3.0.4.tgz", "resolved": "https://registry.npmjs.org/parse-glob/-/parse-glob-3.0.4.tgz",

View File

@ -11,6 +11,8 @@
}, },
"dependencies": { "dependencies": {
"eslint": "^4.16.0", "eslint": "^4.16.0",
"hashmap": "^2.3.0",
"jenkins": "^0.20.1",
"mem-cache": "0.0.5", "mem-cache": "0.0.5",
"probot": "^5.0.0", "probot": "^5.0.0",
"probot-config": "^0.1.0", "probot-config": "^0.1.0",