diff --git a/achievements/breakingBad.achievement.js b/achievements/breakingBad.achievement.js new file mode 100644 index 00000000..34b63f0e --- /dev/null +++ b/achievements/breakingBad.achievement.js @@ -0,0 +1,36 @@ +var _ = require('lodash'); + +var breakingBad = { + name: 'Breaking Bad', + check: function(pullRequest, shall) { + if (atLeast80PrecentCommitsFailedBuild(pullRequest)) { + + var achievement = { + avatar : '//ca.audubon.org/sites/g/files/amh421/f/styles/bean_wysiwyg_full_width/public/blog/wp-content/uploads/2013/09/saul-150x150.jpg?itok=FmjiSkJL', + name: 'Breaking Bad', + short: 'Look, let\'s start with some tough love. You two suck at peddling meth. Period.', + description: 'You merged a Pull Request with 5 or more commits with failing status', + relatedPullRequest: pullRequest._id + }; + + shall.grant(pullRequest.creator.username, achievement); + } + } +}; + +function atLeast80PrecentCommitsFailedBuild(pullRequest) { + var failedCommits = 0; + var totalCommits = pullRequest.commits.length; + _.forEach(pullRequest.commits, function(commit) { + var prBuildStatus = commit.statuses['continuous-integration/travis-ci/pr']; + var pushBuildStatus = commit.statuses['continuous-integration/travis-ci/push']; + if ((prBuildStatus && _.isEqual(prBuildStatus.state, 'error')) || + (pushBuildStatus && _.isEqual(pushBuildStatus.state, 'error'))) { + failedCommits++; + } + }); + + return ((failedCommits / totalCommits) * 100) >= 80; +} + +module.exports = breakingBad; diff --git a/achievibitDB.js b/achievibitDB.js new file mode 100644 index 00000000..d4e00620 --- /dev/null +++ b/achievibitDB.js @@ -0,0 +1,44 @@ +var _ = require('lodash'); +var mongo = require('mongodb'); +var monk = require('monk'); + +var url = process.env.MONGOLAB_URI; +var db = monk(url); + +var collections = { + repos: db.get('repos'), + users: db.get('users') +}; + +var achievibitDB = {}; + +initCollections(); + +achievibitDB.insertItem = insertItem; +achievibitDB.updateItem = updateItem; +achievibitDB.findItem = findItem; + +module.exports = achievibitDB; + +function initCollections() { + collections.repos.index( { fullname: 1 }, { unique: true, sparse: true } ); + collections.users.index( { username: 1 }, { unique: true, sparse: true } ); +} + +function insertItem(collection, item) { + if (_.isNil(collection) || _.isNil(item)) return; + return collections[collection].insert(item); +} + +function updateItem(collection, identityObject, updateWith) { + if (_.isNil(collection) || _.isNil(identityObject) || _.isNil(updateWith)) { + return; + } + + return collections[collection].update(identityObject, updateWith); +} + +function findItem(collection, identityObject) { + if (_.isNil(collection) || _.isNil(identityObject)) return; + return collections[collection].find(identityObject); +} diff --git a/eventManager.js b/eventManager.js index 24ad1d53..7f2d8643 100644 --- a/eventManager.js +++ b/eventManager.js @@ -1,28 +1,26 @@ -var util = require('util'); var EventEmitter = require('events').EventEmitter; var _ = require('lodash'); var github = require('octonode'); -var client = github.client(); var request = require('request'); -var url = process.env.MONGOLAB_URI; -var mongo = require('mongodb'); -var monk = require('monk'); var schema = require('js-schema'); -var db = monk(url); +var async = require("async"); +var achievibitDB = require('./achievibitDB'); +var utilities = require('./utilities'); + +// require all the achievement files var achievements = require('require-all')({ dirname : __dirname + '/achievements', filter : /(.+achievement)\.js$/, excludeDirs : /^\.(git|svn)$/, recursive : true }); -var alreadyReturned = { - comments: false, - commits: false, - reactions: [], - inlineComments: false, - files: false -}; +var client = github.client({ + username: 'k1bib0t', + password: 'a5b4c3d2e1!' +}); + +// OUR SCHEMAS! var Achievement = schema({ avatar : String, name : String, @@ -38,36 +36,21 @@ var Treasure = schema({ var pullRequests = {}; -// @station - an object with `freq` and `name` properties var EventManager = function() { - - // we need to store the reference of `this` to `self`, so that we can use the current context in the setTimeout (or any callback) functions - // using `this` in the setTimeout functions will refer to those funtions, not the Radio class var self = this; - + self.notifyAchievements = function(githubEvent, eventData, io) { /** * NEW REPO CONNECTED!!! */ if (_.isEqual(githubEvent, 'ping')) { - var repo = { - name: eventData.repository.name, - fullname: eventData.repository.full_name, - url: eventData.repository.html_url - }; - - if (_.isEqual(eventData.repository.owner.type, 'Organization')) { - repo.organization = { - username: eventData.repository.owner.login, - url: eventData.repository.owner.html_url, - avatar: eventData.repository.owner.avatar_url, - organization: true - }; + var repo = utilities.parseRepo(eventData.repository); + + if (utilities.isPullRequestAssociatedWithOrganization(eventData)) { + repo.organization = utilities.parseUser(eventData.repository.owner, true); } - var repos = db.get('repos'); - repos.index( { fullname: 1 }, { unique: true, sparse: true } ); - repos.insert(repo); + achievibitDB.insertItem('repos', repo); } /** @@ -81,44 +64,23 @@ var EventManager = function() { if (_.isEqual(githubEvent, 'pull_request') && _.isEqual(eventData.action, 'opened')) { //_.isEqual(eventData.action, 'reopened') ///// - var id = getPullRequestId(eventData); + var id = utilities.getPullRequestIdFromEventData(eventData); //var pullRequests = db.get('pullRequests'); if (!pullRequests[id]) { pullRequests[id] = {}; } - _.merge(pullRequests[id], { - id: id, - url: eventData.pull_request.html_url, - number: eventData.number, - title: eventData.pull_request.title, - description: eventData.pull_request.body, - creator: createUserData(eventData.pull_request.user), - createdOn: eventData.pull_request.created_at, - //milestone: eventData.pull_request.milestone, - labels: [], - history: {}, - repository: { - name: eventData.repository.name, - fullname: eventData.repository.full_name, - url: eventData.repository.html_url - } - }); + _.merge(pullRequests[id], utilities.initializePullRequest(eventData)); - if (_.isEqual(eventData.repository.owner.type, 'Organization')) { - pullRequests[id].organization = { - username: eventData.repository.owner.login, - url: eventData.repository.owner.html_url, - avatar: eventData.repository.owner.avatar_url, - organization: true - }; + if (utilities.isPullRequestAssociatedWithOrganization(eventData)) { + pullRequests[id].organization = utilities.parseUser(eventData.repository.owner, true); } if (eventData.pull_request.assignees) { var assignees = eventData.pull_request.assignees; pullRequests[id].reviewers = []; for (var i = 0; i < assignees.length; i++) { - pullRequests[id].reviewers.push(createUserData(assignees[i])); + pullRequests[id].reviewers.push(utilities.parseUser(assignees[i])); } } @@ -138,7 +100,7 @@ var EventManager = function() { eventData.pull_request.created_at) ) { ///// - var id = getPullRequestId(eventData); + var id = utilities.getPullRequestIdFromEventData(eventData); pullRequests[id] .labels.push(eventData.label.name); @@ -150,7 +112,7 @@ var EventManager = function() { } else if (_.isEqual(githubEvent, 'pull_request') && _.isEqual(eventData.action, 'labeled')) { ///// - var id = getPullRequestId(eventData); + var id = utilities.getPullRequestIdFromEventData(eventData); if (!_.isObject(pullRequests[id].history.labels)) { pullRequests[id].history.labels = { added: 0, @@ -172,7 +134,7 @@ var EventManager = function() { if (_.isEqual(githubEvent, 'pull_request') && _.isEqual(eventData.action, 'unlabeled')) { ///// - var id = getPullRequestId(eventData); + var id = utilities.getPullRequestIdFromEventData(eventData); if (!_.isObject(pullRequests[id].history.labels)) { pullRequests[id].history.labels = { added: 0, @@ -208,11 +170,11 @@ var EventManager = function() { // * log status updates (can give achievements on flawless builds, etc.) // Those will be logged here by other "ifs", and this if will trigger all // the relevant events based on that data - + // first, get some additional data: // * comments // * reactions - var id = getPullRequestId(eventData); + var id = utilities.getPullRequestIdFromEventData(eventData); // if by any chance we missed creating the pull request, // create it here (means we won't have history) @@ -223,11 +185,12 @@ var EventManager = function() { // update to latest info for some things _.assign(pullRequests[id], { _id: id, + id: id, url: eventData.pull_request.html_url, number: eventData.number, title: eventData.pull_request.title, description: eventData.pull_request.body, - creator: createUserData(eventData.pull_request.user), + creator: utilities.parseUser(eventData.pull_request.user), createdOn: eventData.pull_request.created_at, repository: { name: eventData.repository.name, @@ -237,91 +200,119 @@ var EventManager = function() { }); var ghpr = client.pr(eventData.repository.full_name, eventData.number); var ghissue = client.issue(eventData.repository.full_name, eventData.number); - alreadyReturned = { - comments: false, - commits: false, - reactions: [], - inlineComments: false, - files: false - }; - ghissue.comments(function(err, comments) { - if (err) throw err; - - alreadyReturned.reactions = - _.times(comments.length, _.constant(false)); - pullRequests[id].comments = []; - _.forEach(comments, function(comment) { - var commentParsed = { - author: createUserData(comment.user), - message: comment.body, - createdOn: comment.created_at, - edited: !_.isEqual(comment.created_at, comment.updated_at), - apiUrl: comment.url - }; - pullRequests[id].comments.push(commentParsed); - - getReactions(commentParsed, id); - }); - - alreadyReturned.comments = true; - dataReady(id, io); - }); - ghpr.commits(function(err, commits) { - if (err) throw err; - - pullRequests[id].commits = []; - _.forEach(commits, function(commit) { - pullRequests[id].commits.push({ - sha: commit.sha, - author: createUserData(commit.author), - committer: createUserData(commit.committer), - message: commit.commit.message, - commentCount: commit.comments - - }); - }); + var ghrepo = client.repo(eventData.repository.full_name); + + async.parallel([ + function fetchPRComments(callback) { + ghissue.comments(function(err, comments) { + if (err) { + callback(err); + return; + } - alreadyReturned.commits = true; - dataReady(id, io); - }); - ghpr.comments(function(err, inlineComments) { - if (err) throw err; - - alreadyReturned.reactions = - alreadyReturned.reactions.concat(_.times(inlineComments.length, _.constant(false))); - pullRequests[id].inlineComments = []; - _.forEach(inlineComments, function(inlineComment) { - var inlineCommentParsed = { - file: inlineComment.path, - author: createUserData(inlineComment.user), - message: inlineComment.body, - createdOn: inlineComment.created_at, - edited: !_.isEqual(inlineComment.created_at, inlineComment.updated_at), - commit: inlineComment.commit_id, - apiUrl: inlineComment.url - - }; - pullRequests[id].inlineComments.push(inlineCommentParsed); - - getReactions(inlineCommentParsed, id); - }); + pullRequests[id].comments = []; + var reactionsRequests = []; - alreadyReturned.inlineComments = true; - dataReady(id, io); - }); + _.forEach(comments, function(comment) { + var commentParsed = { + author: utilities.parseUser(comment.user), + message: comment.body, + createdOn: comment.created_at, + edited: !_.isEqual(comment.created_at, comment.updated_at), + apiUrl: comment.url + }; + pullRequests[id].comments.push(commentParsed); - ghpr.files(function(err, files) { - if (err) throw err; + reactionsRequests.push(getReactions(commentParsed)); + }); - pullRequests[id].files = []; - _.forEach(files, function(file) { - pullRequests[id].files.push({ - content: getNewFileFromPatch(file.patch), - name: file.filename + async.parallel(reactionsRequests, function(err, results) { + if (err) { + callback(err); + return; + } + callback(null, 'comments & reactions fetched'); }); - }); + }); + }, + function fetchInlineComments(callback) { + ghpr.comments(function(err, inlineComments) { + if (err) { + callback(err); + return; + } + + pullRequests[id].inlineComments = []; + var reactionsRequests = []; + + _.forEach(inlineComments, function(inlineComment) { + var inlineCommentParsed = { + file: inlineComment.path, + author: utilities.parseUser(inlineComment.user), + message: inlineComment.body, + createdOn: inlineComment.created_at, + edited: !_.isEqual(inlineComment.created_at, inlineComment.updated_at), + commit: inlineComment.commit_id, + apiUrl: inlineComment.url + + }; + pullRequests[id].inlineComments.push(inlineCommentParsed); + reactionsRequests.push(getReactions(inlineCommentParsed)); + }); + + async.parallel(reactionsRequests, function(err, results) { + if (err) { + callback(err); + return; + } + callback(null, 'inline comments & reactions fetched'); + }); + }); + }, + function fetchCommits(callback) { + ghpr.commits(function(err, commits) { + if (err) { + callback(err); + return; + } + + pullRequests[id].commits = []; + _.forEach(commits, function(commit) { + ghrepo.statuses(commit.sha, function(err, statuses) { + pullRequests[id].commits.push({ + sha: commit.sha, + author: utilities.parseUser(commit.author), + committer: utilities.parseUser(commit.committer), + message: commit.commit.message, + commentCount: commit.comments, + statuses: utilities.parseStatuses(statuses) + }); + }); + }); + + callback(null, 'commits fetched'); + }); + }, + function fetchPRFiles(callback) { + ghpr.files(function(err, files) { + if (err) { + callback(err); + return; + } - alreadyReturned.files = true; + pullRequests[id].files = []; + _.forEach(files, function(file) { + pullRequests[id].files.push({ + content: getNewFileFromPatch(file.patch), + name: file.filename + }); + }); + + callback(null, 'files fetched'); + }); + } + ], function(err, results) { + console.log('got all async data. continuing...'); dataReady(id, io); }); } @@ -335,7 +326,7 @@ var EventManager = function() { if (_.isEqual(githubEvent, 'pull_request') && _.isEqual(eventData.action, 'edited')) { ///// - var id = getPullRequestId(eventData); + var id = utilities.getPullRequestIdFromEventData(eventData); if (eventData.changes.title) { var oldTitle = pullRequests[id].title; @@ -369,7 +360,7 @@ var EventManager = function() { console.log('UPDATE description', JSON.stringify(pullRequests[id], null, 2)); } - + } /** @@ -380,14 +371,14 @@ var EventManager = function() { if (_.isEqual(githubEvent, 'pull_request') && (_.isEqual(eventData.action, 'unassigned') || _.isEqual(eventData.action, 'assigned'))) { ///// - var id = getPullRequestId(eventData); + var id = utilities.getPullRequestIdFromEventData(eventData); if (!pullRequests[id]) { pullRequests[id] = {}; } var assignees = eventData.pull_request.assignees; pullRequests[id].reviewers = []; for (var i = 0; i < assignees.length; i++) { - pullRequests[id].reviewers.push(createUserData(assignees[i])); + pullRequests[id].reviewers.push(utilities.parseUser(assignees[i])); } console.log('UPDATE assignees', JSON.stringify(pullRequests[id], null, 2)); @@ -412,62 +403,56 @@ var EventManager = function() { }).join('\n'); } - function getReactions(comment, id) { - request({ - url: comment.apiUrl + '/reactions', - headers: { - 'Accept': 'application/vnd.github.squirrel-girl-preview', - 'User-Agent': 'achievibit' - } - }, function(err, response, body) { - if (err) throw err; - - var index = alreadyReturned.reactions.indexOf(false); - alreadyReturned.reactions[index] = true; - if (response.statusCode == 200) { - - var reactions = JSON.parse(body); - comment.reactions = []; - _.forEach(reactions, function(reaction) { - comment.reactions.push({ - reaction: reaction.content, - user: createUserData(reaction.user) - }); - }); - } else { - console.error('wrong status from server: [' + - response.statusCode + - '] ' + - body); - } - dataReady(id, io); - }); + function getReactions(comment) { + return function getSpecificCommentReactions(callback) { + request({ + url: comment.apiUrl + '/reactions', + headers: { + 'Accept': 'application/vnd.github.squirrel-girl-preview', + 'User-Agent': 'achievibit' + } + }, function(err, response, body) { + if (err) { + callback(err); + return; + } + + if (response.statusCode == 200) { + + var reactions = JSON.parse(body); + comment.reactions = []; + _.forEach(reactions, function(reaction) { + comment.reactions.push({ + reaction: reaction.content, + user: utilities.parseUser(reaction.user) + }); + }); + } else { + console.error('wrong status from server: [' + + response.statusCode + + '] ' + + body); + } + callback(null, 'reactions ready'); + }); + }; } function dataReady(id, io) { - if (alreadyReturned && - alreadyReturned.comments && - alreadyReturned.commits && - _.every(alreadyReturned.reactions) && - alreadyReturned.inlineComments && - alreadyReturned.files) { - var allUsersUsernames = []; console.log('~~== PULL REQUEST MERGED! ==~~'); // add creator to database console.log('adding users to database'); - var _users = db.get('users'); - _users.index( { username: 1 }, { unique: true, sparse: true } ); - _users.insert(pullRequests[id].creator); + achievibitDB.insertItem('users', pullRequests[id].creator); allUsersUsernames.push({ username: pullRequests[id].creator.username }); console.log('adding user: ' + pullRequests[id].creator.username); // add organization to database console.log('adding organization to database'); if (pullRequests[id].organization) { - _users.insert(pullRequests[id].organization); + achievibitDB.insertItem('users', pullRequests[id].organization); allUsersUsernames.push({ username: pullRequests[id].organization.username }); console.log('adding user (organization): ' + pullRequests[id].organization.username); //addOrganization(pullRequests[id].creator.username, pullRequests[id].organization); @@ -475,21 +460,18 @@ var EventManager = function() { // add reviewers to database _.forEach(pullRequests[id].reviewers, function(reviewer) { - _users.insert(reviewer); + achievibitDB.insertItem('users', reviewer); allUsersUsernames.push({ username: reviewer.username }); console.log('adding user: ' + reviewer.username); //addOrganization(reviewer.username, pullRequests[id].organization); }); - console.log('adding repo to database: ' + pullRequests[id].repository); - - var repos = db.get('repos'); - repos.index( { fullname: 1 }, { unique: true, sparse: true } ); - repos.insert(pullRequests[id].repository); + console.log('adding repo to database: ' + pullRequests[id].repository.fullname); + achievibitDB.insertItem('repos', pullRequests[id].repository); console.log('~~== CHECKING ACHIEVEMENTS ==~~'); - _users.find({$or: allUsersUsernames}).then(function(users) { + achievibitDB.findItem('users', {$or: allUsersUsernames}).then(function(users) { if (!users) { console.error('couldn\'t find users', allUsersUsernames); return; @@ -497,31 +479,33 @@ var EventManager = function() { var organization = _.remove(users, 'organization')[0]; - _.forEach(users, function(user) { - if (!_.isArray(user.organizations)) { - user.organizations = []; - } + if (organization) { + _.forEach(users, function(user) { + if (!_.isArray(user.organizations)) { + user.organizations = []; + } - if (!_.find(user.organizations, { username: organization.username })) { - user.organizations.push({ - username: organization.username, - url: organization.url, - avatar: organization.avatar - }); - } + if (!_.find(user.organizations, { username: organization.username })) { + user.organizations.push({ + username: organization.username, + url: organization.url, + avatar: organization.avatar + }); + } - if (!_.isArray(organization.users)) { - organization.users = []; - } + if (!_.isArray(organization.users)) { + organization.users = []; + } - if (!_.find(organization.users, { username: user.username })) { - organization.users.push({ - username: user.username, - url: user.url, - avatar: user.avatar - }); - } - }); + if (!_.find(organization.users, { username: user.username })) { + organization.users.push({ + username: user.username, + url: user.url, + avatar: user.avatar + }); + } + }); + } var grantedAchievements = {}; @@ -552,18 +536,18 @@ var EventManager = function() { } else { console.error(achievementObject.name || achievementFilename + ': didn\'t get the correct structure. see documentation'); + console.log('acievement problems:'); + console.log(Achievement.errors(achievementObject)); } }, pass: function(username, treasure) { if (Treasure(treasure)) { var treasures = {}; treasures[treasure.name] = treasure; - _users.find({ username: username }).then(function(user) { + achievibitDB.findItem('users', { username: username }).then(function(user) { console.log(user); }); - _users.update({ username: username }, { - treasures: treasures - }); + achievibitDB.updateItem('users', { username: username }, { treasures: treasures }); } }, retrieve: function(username) { @@ -571,7 +555,7 @@ var EventManager = function() { console.error('retrieve expects a username and achievement name'); return; } - return _users.find({ username: username }); + return achievibitDB.findItem('users', { username: username }); } }; achievement.check && achievement.check(pullRequests[id], shall); @@ -601,10 +585,7 @@ var EventManager = function() { } console.log('updating user: ' + user.username); - _users.update(user._id, user, undefined, function(err) { - console.log('USER ' + user.username + 'UPDATE CALLBACK'); - console.error(err); - }); + achievibitDB.updateItem('users', user._id, user); }); if (pullRequests[id].repository) { @@ -617,39 +598,11 @@ var EventManager = function() { } } - _users.update(organization._id, organization, undefined, function(err) { - console.log('USER ' + user.username + 'UPDATE CALLBACK'); - console.error(err); - }); - - alreadyReturned = { - comments: false, - commits: false, - reactions: [], - inlineComments: false, - files: false - }; + achievibitDB.updateItem('users', organization._id, organization); }); - } } - - function createUserData(githubUser) { - return { - username: githubUser.login, - url: githubUser.html_url, - avatar: githubUser.avatar_url - }; - } - - function getPullRequestId(eventData) { - return eventData.repository.full_name + '/pull/' + eventData.number; - } - }; -// extend the EventEmitter class using our Radio class -util.inherits(EventManager, EventEmitter); - var eventManager = new EventManager(); module.exports = eventManager; diff --git a/package.json b/package.json index 1b555a4a..a004666a 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "author": "neilkalman@gmail.com", "license": "ISC", "dependencies": { + "async": "^2.1.4", "body-parser": "^1.15.2", "colors": "^1.1.2", "compression": "^1.6.2", diff --git a/utilities.js b/utilities.js new file mode 100644 index 00000000..f8452758 --- /dev/null +++ b/utilities.js @@ -0,0 +1,89 @@ +var _ = require('lodash'); + +var utilities = {}; + +utilities.parseStatuses = parseStatuses; +utilities.parseUser = parseUser; +utilities.getNewFileFromPatch = getNewFileFromPatch; +utilities.getPullRequestIdFromEventData = getPullRequestIdFromEventData; +utilities.parseRepo = parseRepo; +utilities.isPullRequestAssociatedWithOrganization = isPullRequestAssociatedWithOrganization; +utilities.initializePullRequest = initializePullRequest; + +module.exports = utilities; + +function isPullRequestAssociatedWithOrganization(eventData) { + return _.isEqual(eventData.repository.owner.type, 'Organization'); +} + +function initializePullRequest(eventData) { + return { + id: utilities.getPullRequestIdFromEventData(eventData), + url: eventData.pull_request.html_url, + number: eventData.number, + title: eventData.pull_request.title, + description: eventData.pull_request.body, + creator: utilities.parseUser(eventData.pull_request.user), + createdOn: eventData.pull_request.created_at, + //milestone: eventData.pull_request.milestone, + labels: [], + history: {}, + repository: utilities.parseRepo(eventData.repository) + }; +} + +function parseRepo(repository) { + return { + name: repository.name, + fullname: repository.full_name, + url: repository.html_url + }; +} + +function parseStatuses(statuses) { + var parsed = {}; + _.forEach(statuses, function(status) { + // nothing should be pending if the pull request got merged + if (!_.isEqual(status.state, 'pending')) { + parsed[status.context] = { + state: status.state, + description: status.description, + target: status.target_url, + context: status.context + }; + } + }); + + return parsed; +} + +function parseUser(githubUser, isOrganization) { + var user = { + username: githubUser.login, + url: githubUser.html_url, + avatar: githubUser.avatar_url + }; + + if (isOrganization) { + user.organization = true; + } + + return user; +} + +function getNewFileFromPatch(patch) { + if (!patch) { + return; + } + return patch.split('\n').filter(function(line) { + return !line.startsWith('-') && !line.startsWith('@') && line.indexOf('No newline at end of file') === -1; + }).map(function(line) { + if (line.startsWith('+')) + return line.replace('+', ''); + return line; + }).join('\n'); +} + +function getPullRequestIdFromEventData(eventData) { + return eventData.repository.full_name + '/pull/' + eventData.number; +}