From 6bc6142fad112127e0cddd141118d2bd53b0ed0d Mon Sep 17 00:00:00 2001 From: Pedro Pombeiro Date: Wed, 17 Jan 2018 18:49:14 +0100 Subject: [PATCH] Add support for installing as a GitHub app. Part of #1 --- package-lock.json | 5 ++ package.json | 3 +- scripts/assign-new-pr-to-review.coffee | 30 ++++++--- scripts/github-context.coffee | 89 +++++++++++++++++++------- scripts/github-installation.coffee | 76 ++++++++++++++++++++++ scripts/greet-new-contributor.coffee | 38 +++++++---- 6 files changed, 197 insertions(+), 44 deletions(-) create mode 100644 scripts/github-installation.coffee diff --git a/package-lock.json b/package-lock.json index 08cd38b..4ac4995 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1192,6 +1192,11 @@ } } }, + "jwt-simple": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/jwt-simple/-/jwt-simple-0.5.1.tgz", + "integrity": "sha1-eeoBiRth3mto4T5nwLS1vak3spQ=" + }, "lodash": { "version": "3.10.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-3.10.1.tgz", diff --git a/package.json b/package.json index 704b9f3..adc09e8 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "hubot-rules": "^0.1.2", "hubot-scripts": "^2.17.2", "hubot-shipit": "^0.2.1", - "hubot-slack": "^4.4.0" + "hubot-slack": "^4.4.0", + "jwt-simple": "^0.5.1" }, "engines": { "node": "0.10.x" diff --git a/scripts/assign-new-pr-to-review.coffee b/scripts/assign-new-pr-to-review.coffee index 769d67a..8548e9c 100644 --- a/scripts/assign-new-pr-to-review.coffee +++ b/scripts/assign-new-pr-to-review.coffee @@ -1,6 +1,19 @@ # Description: # Script that listens to new GitHub pull requests # and assigns them to the REVIEW column on the "Pipeline for QA" project +# +# Dependencies: +# github: "^13.1.0" +# hubot-github-webhook-listener: "^0.9.1" +# hubot-slack: "^4.4.0" +# +# Notes: +# The hard-coded names for the project board and review column are just below. +# These could be read from a config file (e.g. YAML) +# TODO: Rewrite this file with ES6 to benefit from async/await +# +# Author: +# PombeirP projectBoardName = "Pipeline for QA" reviewColumnName = "REVIEW" @@ -8,32 +21,32 @@ notifyRoomName = "core" module.exports = (robot) -> - context = require("./github-context.coffee") + context = require('./github-context.coffee') robot.on "github-repo-event", (repo_event) -> githubPayload = repo_event.payload switch(repo_event.eventType) when "pull_request" + context.initialize(robot, robot.brain.get "github-app_id") # Make sure we don't listen to our own messages return if context.equalsRobotName(robot, githubPayload.pull_request.user.login) - return console.error "No Github token provided to Hubot" unless process.env.HUBOT_GITHUB_TOKEN action = githubPayload.action if action == "opened" # A new PR was opened - context.initialize() - - assignPullRequestToReview context.github, githubPayload, robot + assignPullRequestToReview context.github(), githubPayload, robot assignPullRequestToReview = (github, githubPayload, robot) -> ownerName = githubPayload.repository.owner.login repoName = githubPayload.repository.name prNumber = githubPayload.pull_request.number - robot.logger.info "assignPullRequestToReview - Handling Pull Request ##{prNumber} on repo #{ownerName}/#{repoName}" + robot.logger.info "assignPullRequestToReview - " + + "Handling Pull Request ##{prNumber} on repo #{ownerName}/#{repoName}" # Fetch repo projects - # TODO: The repo project and project column info should be cached in order to improve performance and reduce roundtrips + # TODO: The repo project and project column info should be cached + # in order to improve performance and reduce roundtrips github.projects.getRepoProjects { owner: ownerName, repo: repoName, @@ -73,7 +86,7 @@ assignPullRequestToReview = (github, githubPayload, robot) -> column_id: column.id, content_type: 'PullRequest', content_id: githubPayload.pull_request.id - }, (err, ghcard) -> + }, (err, ghcard) -> if err robot.logger.error "Couldn't create project card for the PR: #{err}", column.id, githubPayload.pull_request.id @@ -86,7 +99,6 @@ assignPullRequestToReview = (github, githubPayload, robot) -> "Moved PR #{githubPayload.pull_request.number} to " + "#{reviewColumnName} in #{projectBoardName} project" - findProject = (projects, name) -> for idx, project of projects return project if project.name == name diff --git a/scripts/github-context.coffee b/scripts/github-context.coffee index ed6119b..bdbff63 100644 --- a/scripts/github-context.coffee +++ b/scripts/github-context.coffee @@ -1,36 +1,81 @@ # Description: # Script that keeps GitHub-related context to be shared among scripts +# +# Dependencies: +# github: "^13.1.0" +# jwt-simple: "^0.5.1" +# +# Author: +# PombeirP -GitHubApi = require("github") +GitHubApi = require('github') RegExp cachedRobotNameRegex = null initialized = false -github = new GitHubApi { version: "3.0.0" } +githubAPI = new GitHubApi { version: "3.0.0" } -module.exports.github = github +module.exports = { -module.exports.initialize = -> + github: -> githubAPI + + initialize: (robot, integrationID) -> return if initialized - initialized = true - github.authenticate({ - type: "token", - token: process.env.HUBOT_GITHUB_TOKEN + token = robot.brain.get('github-token') + if token + initialized = true + process.env.HUBOT_GITHUB_TOKEN = token + robot.logger.debug "Reused cached GitHub token" + githubAPI.authenticate({ type: 'token', token: token }) + return + + pemFilePath = './status-github-bot.pem' + + jwt = require('jwt-simple') + + # Private key contents + privateKey = '' + try + fs = require('fs') + privateKey = fs.readFileSync pemFilePath + catch err + robot.logger.error "Couldn't read #{pemFilePath} file contents: #{err}" + return + + now = Math.round(Date.now() / 1000) + # Generate the JWT + payload = { + # issued at time + iat: now, + # JWT expiration time (10 minute maximum) + exp: now + (1 * 60), + # GitHub App's identifier + iss: integrationID + } + + jwt = jwt.encode(payload, privateKey, 'RS256') + githubAPI.authenticate({ + type: 'integration', + token: jwt }) + robot.logger.debug "Configured integration authentication with JWT", jwt -module.exports.equalsRobotName = (robot, str) -> - return module.exports.getRegexForRobotName(robot).test(str) + initialized = true -module.exports.getRegexForRobotName = (robot) -> - # This comes straight out of Hubot's Robot.coffee - # - they didn't get a nice way of extracting that method though - if !cachedRobotNameRegex - name = robot.name.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&') + equalsRobotName: (robot, str) -> + return getRegexForRobotName(robot).test(str) +} - if robot.alias - alias = robot.alias.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&') - namePattern = "^\\s*[@]?(?:#{alias}|#{name})" - else - namePattern = "^\\s*[@]?#{name}" - cachedRobotNameRegex = new RegExp(namePattern, 'i') - return cachedRobotNameRegex +getRegexForRobotName = (robot) -> + # This comes straight out of Hubot's Robot.coffee + # - they didn't get a nice way of extracting that method though + if !cachedRobotNameRegex + name = robot.name.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&') + + if robot.alias + alias = robot.alias.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&') + namePattern = "^\\s*[@]?(?:#{alias}|#{name})" + else + namePattern = "^\\s*[@]?#{name}" + cachedRobotNameRegex = new RegExp(namePattern, 'i') + return cachedRobotNameRegex diff --git a/scripts/github-installation.coffee b/scripts/github-installation.coffee new file mode 100644 index 0000000..a36155a --- /dev/null +++ b/scripts/github-installation.coffee @@ -0,0 +1,76 @@ +# Description: +# Script that handles the installation of the GitHub app +# +# Dependencies: +# github: "^13.1.0" +# hubot-github-webhook-listener: "^0.9.1" +# +# Author: +# PombeirP + +module.exports = (robot) -> + + context = require('./github-context.coffee') + + robot.on "github-repo-event", (repo_event) -> + githubPayload = repo_event.payload + + switch(repo_event.eventType) + when "integration_installation" + # Make sure we don't listen to our own messages + return if context.equalsRobotName(robot, githubPayload.sender.login) + + action = githubPayload.action + switch action + when "created" + # App was installed on an organization + robot.logger.info "Initializing installation for app with ID " + + "#{githubPayload.installation.app_id} and " + + "installation ID #{githubPayload.installation.id}" + + robot.brain.set 'github-app_install-payload', JSON.stringify(githubPayload) + robot.brain.set 'github-app_id', githubPayload.installation.app_id + robot.brain.set 'github-app_repositories', + (x.full_name for x in githubPayload.repositories).join() + + context.initialize(robot, githubPayload.installation.app_id) + + perms = githubPayload.installation.permissions + robot.logger.error formatPermMessage('repository_projects', 'write') unless perms.repository_projects == 'write' + robot.logger.error formatPermMessage('metadata', 'read') unless perms.metadata == 'read' + robot.logger.error formatPermMessage('issues', 'read') unless perms.issues == 'read' + robot.logger.error formatPermMessage('pull_requests', 'write') unless perms.pull_requests == 'write' + + robot.logger.error "Please enable 'pull_request' events " + + "in the app configuration on github.com" unless 'pull_request' in githubPayload.installation.events + + createAccessToken robot, context.github(), githubPayload.installation.id + when "deleted" + # App was uninstalled from an organization + robot.logger.info "Removing installation for app " + + "with ID #{githubPayload.installation.app_id} and " + + "installation ID #{githubPayload.installation.id}" + + robot.brain.set 'github-app_id', null + robot.brain.set 'github-app_install-payload', null + robot.brain.set 'github-app_repositories', null + robot.brain.set 'github-token', null + process.env.HUBOT_GITHUB_TOKEN = null + +createAccessToken = (robot, github, id) -> + github.apps.createInstallationToken { installation_id: id }, (err, response) -> + if err + robot.logger.error "Couldn't create installation token: #{err}", id + return + + console.error response.data.token + robot.brain.set 'github-token', response.data.token + # TODO: Set Redis expiration date to value from response.data.expires_at + process.env.HUBOT_GITHUB_TOKEN = response.data.token + github.authenticate({ + type: 'token', + token: response.data.token + }) + +formatPermMessage = (permName, perm) -> + "Please enable '#{permName}' #{perm} permission in the app configuration on github.com" \ No newline at end of file diff --git a/scripts/greet-new-contributor.coffee b/scripts/greet-new-contributor.coffee index 9c8d918..bf21c4e 100644 --- a/scripts/greet-new-contributor.coffee +++ b/scripts/greet-new-contributor.coffee @@ -1,33 +1,43 @@ # Description: # Script that listens to new GitHub pull requests -# and assigns them to the REVIEW column on the "Pipeline for QA" project +# and greets the user if it is their first PR on the repo +# +# Dependencies: +# github: "^13.1.0" +# hubot-github-webhook-listener: "^0.9.1" +# +# Notes: +# TODO: Rewrite this file with ES6 to benefit from async/await +# +# Author: +# PombeirP module.exports = (robot) -> - context = require("./github-context.coffee") + context = require('./github-context.coffee') robot.on "github-repo-event", (repo_event) -> githubPayload = repo_event.payload switch(repo_event.eventType) when "pull_request" + context.initialize(robot, robot.brain.get "github-app_id") # Make sure we don't listen to our own messages return if context.equalsRobotName(robot, githubPayload.pull_request.user.login) - return console.error "No Github token provided to Hubot" unless process.env.HUBOT_GITHUB_TOKEN action = githubPayload.action if action == "opened" # A new PR was opened - context.initialize() - - greetNewContributor context.github, githubPayload, robot + greetNewContributor context.github(), githubPayload, robot greetNewContributor = (github, githubPayload, robot) -> - welcomeMessage = "Thanks for making your first PR here!" # TODO: Read the welcome message from a (per-repo?) file (e.g. status-react.welcome-msg.md) + # TODO: Read the welcome message from a (per-repo?) file (e.g. status-react.welcome-msg.md) + welcomeMessage = "Thanks for making your first PR here!" ownerName = githubPayload.repository.owner.login repoName = githubPayload.repository.name prNumber = githubPayload.pull_request.number - robot.logger.info "greetNewContributor - Handling Pull Request ##{prNumber} on repo #{ownerName}/#{repoName}" + robot.logger.info "greetNewContributor - " + + "Handling Pull Request ##{prNumber} on repo #{ownerName}/#{repoName}" github.issues.getForRepo { owner: ownerName, @@ -48,8 +58,12 @@ greetNewContributor = (github, githubPayload, robot) -> number: prNumber, body: welcomeMessage }, (err, result) -> - if err - robot.logger.error "Couldn't fetch the github projects for repo: #{err}", ownerName, repoName unless err.code == 404 - robot.logger.info "Commented on PR with welcome message", ownerName, repoName + if err + robot.logger.error("Couldn't fetch the github projects for repo: #{err}", + ownerName, repoName) unless err.code == 404 + return + robot.logger.info "Commented on PR with welcome message", ownerName, repoName else - robot.logger.debug "This is not the user's first PR on the repo, ignoring", ownerName, repoName, githubPayload.pull_request.user.login \ No newline at end of file + robot.logger.debug( + "This is not the user's first PR on the repo, ignoring", + ownerName, repoName, githubPayload.pull_request.user.login) \ No newline at end of file