Skip to content
This repository was archived by the owner on Mar 13, 2025. It is now read-only.

wip changes for #14,#13,#18 #1

Merged
merged 1 commit into from
Jun 20, 2018
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
changes for #14,#13,#18
  • Loading branch information
veshu committed Jun 20, 2018
commit 241e0e76e9125fbf5cfa6723741470b9e91e453d
30 changes: 20 additions & 10 deletions .eslintrc
Original file line number Diff line number Diff line change
@@ -3,11 +3,17 @@
"node": true
},
"parserOptions": {
"ecmaVersion": 8
"ecmaVersion": 8,
"ecmaFeatures": {
"experimentalObjectRestSpread": true
}
},
"extends": "eslint-config-topcoder/nodejs",
"rules": {
"max-len": ["error", 160],
"max-len": [
"error",
160
],
"indent": [
"error",
2
@@ -25,12 +31,16 @@
"always"
],
"no-console": 0,
"comma-dangle": ["error", {
"arrays": "never",
"objects": "never",
"imports": "never",
"exports": "never",
"functions": "ignore"
}]
"comma-dangle": [
"error",
{
"arrays": "never",
"objects": "never",
"imports": "never",
"exports": "never",
"functions": "ignore"
}
],
"max-lines": 0,
}
}
}
5 changes: 4 additions & 1 deletion config/default.js
Original file line number Diff line number Diff line change
@@ -82,5 +82,8 @@ module.exports = {
GITLAB_API_BASE_URL: process.env.GITLAB_API_BASE_URL || 'https://gitlab.com',
PAID_ISSUE_LABEL: process.env.PAID_ISSUE_LABEL || 'Paid',
FIX_ACCEPTED_ISSUE_LABEL: process.env.FIX_ACCEPTED_ISSUE_LABEL || 'Fix accepted',
TC_OR_DETAIL_LINK: process.env.TC_OR_DETAIL_LINK || 'https://software.topcoder-dev.com/review/actions/ViewProjectDetails?pid='
READY_FOR_REVIEW_ISSUE_LABEL: process.env.READY_FOR_REVIEW_ISSUE_LABEL || 'Ready for review',
TC_OR_DETAIL_LINK: process.env.TC_OR_DETAIL_LINK || 'https://software.topcoder-dev.com/review/actions/ViewProjectDetails?pid=',
RETRY_COUNT: process.env.RETRY_COUNT || 3,
RETRY_INTERVAL: process.env.RETRY_INTERVAL || 120000, // 2 minutes
};
3 changes: 3 additions & 0 deletions configuration.md
Original file line number Diff line number Diff line change
@@ -26,6 +26,9 @@ The following config parameters are supported, they are defined in `config/defau
|PAID_ISSUE_LABEL|the label name for paid, should be one of the label configured in topcoder x ui|'Paid'|
|FIX_ACCEPTED_ISSUE_LABEL|the label name for fix accepted, should be one of the label configured in topcoder x ui|'Fix Accepted'|
|TC_OR_DETAIL_LINK|the link to online review detail of challenge| see `default.js`, OR link for dev environment|
|RETRY_COUNT| the number of times an event should be retried to process| 3|
|RETRY_INTERVAL| the interval at which the event should be retried to process in milliseconds | 120000|
|READY_FOR_REVIEW_ISSUE_LABEL| the label name for ready for review, should be one of the label configured in topcoder x ui|'Ready for review'|

KAFKA_OPTIONS should be object as described in https://github.com/SOHU-Co/kafka-node#kafkaclient
For using with SSL, the options should be as
5 changes: 4 additions & 1 deletion constants.js
Original file line number Diff line number Diff line change
@@ -22,7 +22,10 @@ const USER_ROLES = {
OWNER: 'owner'
};

const SERVICE_ERROR_STATUS = 500;

module.exports = {
USER_ROLES,
USER_TYPES
USER_TYPES,
SERVICE_ERROR_STATUS
};
311 changes: 97 additions & 214 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -23,10 +23,11 @@
"dependencies": {
"axios": "^0.16.2",
"config": "^1.26.2",
"get-parameter-names": "^0.3.0",
"github": "^12.0.2",
"joi": "^13.0.0",
"jwt-decode": "^2.2.0",
"kafka-node": "^2.2.3",
"kafka-node": "^2.6.1",
"lodash": "^4.17.4",
"markdown-it": "^8.4.0",
"moment": "^2.19.1",
@@ -37,6 +38,7 @@
"topcoder-api-projects": "^1.0.1",
"topcoder-dev-api-challenges": "^1.0.6",
"topcoder-dev-api-projects": "^1.0.1",
"util": "^0.11.0",
"winston": "^2.3.1"
},
"devDependencies": {
2 changes: 2 additions & 0 deletions services/EmailService.js
Original file line number Diff line number Diff line change
@@ -62,3 +62,5 @@ sendNewBidEmail.schema = {
module.exports = {
sendNewBidEmail
};

logger.buildService(module.exports);
111 changes: 84 additions & 27 deletions services/GithubService.js
Original file line number Diff line number Diff line change
@@ -14,6 +14,7 @@ const Joi = require('joi');
const GitHubApi = require('github');
const config = require('config');
const logger = require('../utils/logger');
const errors = require('../utils/errors');

const copilotUserSchema = Joi.object().keys({
accessToken: Joi.string().required(),
@@ -28,12 +29,16 @@ const copilotUserSchema = Joi.object().keys({
* @private
*/
async function _authenticate(accessToken) {
const github = new GitHubApi();
github.authenticate({
type: 'oauth',
token: accessToken
});
return github;
try {
const github = new GitHubApi();
github.authenticate({
type: 'oauth',
token: accessToken
});
return github;
} catch (err) {
throw errors.convertGitHubError(err, 'Failed to authenticate to Github using access token of copilot.');
}
}

/**
@@ -46,14 +51,18 @@ async function _authenticate(accessToken) {
* @private
*/
async function _removeAssignees(github, owner, repo, number, assignees) {
await github.issues.removeAssigneesFromIssue({
owner,
repo,
number,
body: {
assignees
}
});
try {
await github.issues.removeAssigneesFromIssue({
owner,
repo,
number,
body: {
assignees
}
});
} catch (err) {
throw errors.convertGitHubError(err, 'Error occurred during remove assignees from issue.');
}
}

/**
@@ -78,7 +87,11 @@ async function updateIssue(copilot, repo, number, title) {
Joi.attempt({copilot, repo, number, title}, updateIssue.schema);
const github = await _authenticate(copilot.accessToken);
const owner = await _getUsernameById(github, copilot.userProviderId);
await github.issues.edit({owner, repo, number, title});
try {
await github.issues.edit({owner, repo, number, title});
} catch (err) {
throw errors.convertGitHubError(err, 'Error occurred during updating issue.');
}
logger.debug(`Github issue title is updated for issue number ${number}`);
}

@@ -100,13 +113,17 @@ async function assignUser(copilot, repo, number, user) {
Joi.attempt({copilot, repo, number, user}, assignUser.schema);
const github = await _authenticate(copilot.accessToken);
const owner = await _getUsernameById(github, copilot.userProviderId);
try {
const issue = await github.issues.get({owner, repo, number});

const issue = await github.issues.get({owner, repo, number});
const oldAssignees = _(issue.data.assignees).map('login').without(user).value();
if (oldAssignees && oldAssignees.length > 0) {
await _removeAssignees(github, owner, repo, number, oldAssignees);
const oldAssignees = _(issue.data.assignees).map('login').without(user).value();
if (oldAssignees && oldAssignees.length > 0) {
await _removeAssignees(github, owner, repo, number, oldAssignees);
}
await github.issues.addAssigneesToIssue({owner, repo, number, assignees: [user]});
} catch (err) {
throw errors.convertGitHubError(err, 'Error occurred during assigning issue user.');
}
await github.issues.addAssigneesToIssue({owner, repo, number, assignees: [user]});
logger.debug(`Github issue with number ${number} is assigned to ${user}`);
}

@@ -129,7 +146,6 @@ async function removeAssign(copilot, repo, number, user) {

const github = await _authenticate(copilot.accessToken);
const owner = await _getUsernameById(github, copilot.userProviderId);

await _removeAssignees(github, owner, repo, number, [user]);
logger.debug(`Github user ${user} is unassigned from issue number ${number}`);
}
@@ -148,7 +164,11 @@ async function createComment(copilot, repo, number, body) {

const github = await _authenticate(copilot.accessToken);
const owner = await _getUsernameById(github, copilot.userProviderId);
await github.issues.createComment({owner, repo, number, body});
try {
await github.issues.createComment({owner, repo, number, body});
} catch (err) {
throw errors.convertGitHubError(err, 'Error occurred during creating comment on issue.');
}
logger.debug(`Github comment is added on issue with message: "${body}"`);
}

@@ -207,9 +227,13 @@ async function markIssueAsPaid(copilot, repo, number, challengeId) {
const github = await _authenticate(copilot.accessToken);
const owner = await _getUsernameById(github, copilot.userProviderId);
const labels = [config.PAID_ISSUE_LABEL, config.FIX_ACCEPTED_ISSUE_LABEL];
await github.issues.edit({owner, repo, number, labels});
const body = `Payment task has been updated: ${config.TC_OR_DETAIL_LINK}${challengeId}`;
await github.issues.createComment({owner, repo, number, body});
try {
await github.issues.edit({owner, repo, number, labels});
const body = `Payment task has been updated: ${config.TC_OR_DETAIL_LINK}${challengeId}`;
await github.issues.createComment({owner, repo, number, body});
} catch (err) {
throw errors.convertGitHubError(err, 'Error occurred during updating issue as paid.');
}
logger.debug(`Github issue title is updated for as paid and fix accepted for ${number}`);
}

@@ -231,7 +255,11 @@ async function changeState(copilot, repo, number, state) {
Joi.attempt({copilot, repo, number, state}, changeState.schema);
const github = await _authenticate(copilot.accessToken);
const owner = await _getUsernameById(github, copilot.userProviderId);
await github.issues.edit({owner, repo, number, state});
try {
await github.issues.edit({owner, repo, number, state});
} catch (err) {
throw errors.convertGitHubError(err, 'Error occurred during updating status of issue.');
}
logger.debug(`Github issue state is updated to '${state}' for issue number ${number}`);
}

@@ -242,6 +270,32 @@ changeState.schema = {
state: Joi.string().required()
};

/**
* updates the github issue with new labels
* @param {Object} copilot the copilot
* @param {string} repo the repository
* @param {Number} number the issue number
* @param {Number} labels the challenge id
*/
async function addLabels(copilot, repo, number, labels) {
Joi.attempt({copilot, repo, number, labels}, addLabels.schema);
const github = await _authenticate(copilot.accessToken);
const owner = await _getUsernameById(github, copilot.userProviderId);
try {
await github.issues.edit({owner, repo, number, labels});
} catch (err) {
throw errors.convertGitHubError(err, 'Error occurred during adding label in issue.');
}
logger.debug(`Github issue is updated with new labels for ${number}`);
}

addLabels.schema = {
copilot: copilotUserSchema,
repo: Joi.string().required(),
number: Joi.number().required(),
labels: Joi.array().items(Joi.string()).required()
};

module.exports = {
updateIssue,
assignUser,
@@ -250,5 +304,8 @@ module.exports = {
getUsernameById,
getUserIdByLogin,
markIssueAsPaid,
changeState
changeState,
addLabels
};

logger.buildService(module.exports);
97 changes: 77 additions & 20 deletions services/GitlabService.js
Original file line number Diff line number Diff line change
@@ -14,6 +14,7 @@ const Joi = require('joi');
const config = require('config');
const GitlabAPI = require('node-gitlab-api');
const logger = require('../utils/logger');
const errors = require('../utils/errors');

const copilotUserSchema = Joi.object().keys({
accessToken: Joi.string().required(),
@@ -28,11 +29,15 @@ const copilotUserSchema = Joi.object().keys({
* @private
*/
async function _authenticate(accessToken) {
const gitlab = GitlabAPI({
url: config.GITLAB_API_BASE_URL,
oauthToken: accessToken
});
return gitlab;
try {
const gitlab = GitlabAPI({
url: config.GITLAB_API_BASE_URL,
oauthToken: accessToken
});
return gitlab;
} catch (err) {
throw errors.convertGitLabError(err, 'Failed to during authenticate to Github using access token of copilot.');
}
}

/**
@@ -44,9 +49,13 @@ async function _authenticate(accessToken) {
* @private
*/
async function _removeAssignees(gitlab, projectId, issueId, assignees) {
const issue = await gitlab.projects.issues.show(projectId, issueId);
const oldAssignees = _.difference(issue.assignee_ids, assignees);
await gitlab.projects.issues.edit(projectId, issueId, {assignee_ids: oldAssignees});
try {
const issue = await gitlab.projects.issues.show(projectId, issueId);
const oldAssignees = _.difference(issue.assignee_ids, assignees);
await gitlab.projects.issues.edit(projectId, issueId, {assignee_ids: oldAssignees});
} catch (err) {
throw errors.convertGitLabError(err, 'Error occurred during remove assignees from issue.');
}
}

/**
@@ -59,7 +68,11 @@ async function _removeAssignees(gitlab, projectId, issueId, assignees) {
async function createComment(copilot, projectId, issueId, body) {
Joi.attempt({copilot, projectId, issueId, body}, createComment.schema);
const gitlab = await _authenticate(copilot.accessToken);
await gitlab.projects.issues.notes.create(projectId, issueId, {body});
try {
await gitlab.projects.issues.notes.create(projectId, issueId, {body});
} catch (err) {
throw errors.convertGitLabError(err, 'Error occurred during creating comment on issue.');
}
logger.debug(`Gitlab comment is added on issue with message: "${body}"`);
}

@@ -80,7 +93,11 @@ createComment.schema = {
async function updateIssue(copilot, projectId, issueId, title) {
Joi.attempt({copilot, projectId, issueId, title}, updateIssue.schema);
const gitlab = await _authenticate(copilot.accessToken);
await gitlab.projects.issues.edit(projectId, issueId, {title});
try {
await gitlab.projects.issues.edit(projectId, issueId, {title});
} catch (err) {
throw errors.convertGitLabError(err, 'Error occurred during updating issue.');
}
logger.debug(`Gitlab issue title is updated for issue number ${issueId}`);
}

@@ -101,12 +118,16 @@ updateIssue.schema = {
async function assignUser(copilot, projectId, issueId, userId) {
Joi.attempt({copilot, projectId, issueId, userId}, assignUser.schema);
const gitlab = await _authenticate(copilot.accessToken);
const issue = await gitlab.projects.issues.show(projectId, issueId);
const oldAssignees = _.without(issue.assignee_ids, userId);
if (oldAssignees && oldAssignees.length > 0) {
await _removeAssignees(gitlab, projectId, issueId, oldAssignees);
try {
const issue = await gitlab.projects.issues.show(projectId, issueId);
const oldAssignees = _.without(issue.assignee_ids, userId);
if (oldAssignees && oldAssignees.length > 0) {
await _removeAssignees(gitlab, projectId, issueId, oldAssignees);
}
await gitlab.projects.issues.edit(projectId, issueId, {assignee_ids: [userId]});
} catch (err) {
throw errors.convertGitLabError(err, 'Error occurred during assigning issue user.');
}
await gitlab.projects.issues.edit(projectId, issueId, {assignee_ids: [userId]});
logger.debug(`Gitlab issue with number ${issueId} is assigned to ${issueId}`);
}

@@ -179,9 +200,13 @@ getUserIdByLogin.schema = {
async function markIssueAsPaid(copilot, projectId, issueId, challengeId) {
Joi.attempt({copilot, projectId, issueId, challengeId}, markIssueAsPaid.schema);
const gitlab = await _authenticate(copilot.accessToken);
await gitlab.projects.issues.edit(projectId, issueId, {labels: `${config.PAID_ISSUE_LABEL},${config.FIX_ACCEPTED_ISSUE_LABEL}`});
const body = `Payment task has been updated: ${config.TC_OR_DETAIL_LINK}${challengeId}`;
await gitlab.projects.issues.notes.create(projectId, issueId, {body});
try {
await gitlab.projects.issues.edit(projectId, issueId, {labels: `${config.PAID_ISSUE_LABEL},${config.FIX_ACCEPTED_ISSUE_LABEL}`});
const body = `Payment task has been updated: ${config.TC_OR_DETAIL_LINK}${challengeId}`;
await gitlab.projects.issues.notes.create(projectId, issueId, {body});
} catch (err) {
throw errors.convertGitLabError(err, 'Error occurred during updating issue as paid.');
}
logger.debug(`Gitlab issue is updated for as paid and fix accepted for ${issueId}`);
}

@@ -202,7 +227,11 @@ markIssueAsPaid.schema = {
async function changeState(copilot, projectId, issueId, state) {
Joi.attempt({copilot, projectId, issueId, state}, changeState.schema);
const gitlab = await _authenticate(copilot.accessToken);
await gitlab.projects.issues.edit(projectId, issueId, {state_event: state});
try {
await gitlab.projects.issues.edit(projectId, issueId, {state_event: state});
} catch (err) {
throw errors.convertGitLabError(err, 'Error occurred during updating status of issue.');
}
logger.debug(`Gitlab issue state is updated to '${state}' for issue number ${issueId}`);
}

@@ -213,6 +242,31 @@ changeState.schema = {
state: Joi.string().required()
};

/**
* updates the gitlab issue with new labels
* @param {Object} copilot the copilot
* @param {string} projectId the project id
* @param {Number} issueId the issue issue id
* @param {Number} labels the labels
*/
async function addLabels(copilot, projectId, issueId, labels) {
Joi.attempt({copilot, projectId, issueId, labels}, addLabels.schema);
const gitlab = await _authenticate(copilot.accessToken);
try {
await gitlab.projects.issues.edit(projectId, issueId, {labels: _.join(labels, ',')});
} catch (err) {
throw errors.convertGitLabError(err, 'Error occurred during adding label in issue.');
}
logger.debug(`Gitlab issue is updated with new labels for ${issueId}`);
}

addLabels.schema = {
copilot: copilotUserSchema,
projectId: Joi.number().positive().required(),
issueId: Joi.number().required(),
labels: Joi.array().items(Joi.string()).required()
};


module.exports = {
createComment,
@@ -222,5 +276,8 @@ module.exports = {
getUsernameById,
getUserIdByLogin,
markIssueAsPaid,
changeState
changeState,
addLabels
};

logger.buildService(module.exports);
302 changes: 196 additions & 106 deletions services/IssueService.js
Original file line number Diff line number Diff line change
@@ -14,15 +14,16 @@ const _ = require('lodash');
const Joi = require('joi');
const MarkdownIt = require('markdown-it');
const config = require('config');

const models = require('../models');
const logger = require('../utils/logger');
const errors = require('../utils/errors');
const topcoderApiHelper = require('../utils/topcoder-api-helper');
const gitHubService = require('./GithubService');
const emailService = require('./EmailService');
const userService = require('./UserService');
const gitlabService = require('./GitlabService');


const Issue = models.Issue;
const md = new MarkdownIt();

@@ -52,6 +53,52 @@ function parsePrizes(issue) {
issue.title = issue.title.replace(/^(\[.*\])/, '');
}

/**
* handles the event gracefully when there is error processing the event
* @param {Object} event the event
* @param {Object} issue the issue
* @param {Object} err the error
*/
async function handleEventGracefully(event, issue, err) {
if (err.errorAt === 'topcoder' || err.errorAt === 'processor') {
event.retryCount = _.toInteger(event.retryCount);
// reschedule event
if (event.retryCount <= config.RETRY_COUNT) {
logger.debug('Scheduling event for next retry');
const newEvent = {...event};
newEvent.retryCount += 1;
delete newEvent.copilot;
setTimeout(async () => {
const kafka = require('../utils/kafka'); // eslint-disable-line
await kafka.send(JSON.stringify(newEvent));
logger.debug('The event is scheduled for retry');
}, config.RETRY_INTERVAL);
}
let comment = `[${err.statusCode}]: ${err.message}`;
if (event.event === 'issue.closed' && event.paymentSuccessful === false) {
comment = `Payment failed: ${comment}`;
}
// notify error in git host
if (event.provider === 'github') {
await gitHubService.createComment(event.copilot, event.data.repository.name, issue.number, comment);
} else {
await gitlabService.createComment(event.copilot, event.data.repository.id, issue.number, comment);
}
if (event.event === 'issue.closed') {
// reopen
await reOpenIssue(event, issue);
// ensure label is ready for review
const readyForReviewLabels = [config.READY_FOR_REVIEW_ISSUE_LABEL];
if (event.provider === 'github') {
await gitHubService.addLabels(event.copilot, event.data.repository.name, issue.number, readyForReviewLabels);
} else {
await gitlabService.addLabels(event.copilot, event.data.repository.id, issue.number, readyForReviewLabels);
}
}
}
throw err;
}

/**
* check if challenge is exists for given issue in db/topcoder
* @param {Object} issue the issue
@@ -66,7 +113,7 @@ async function ensureChallengeExists(issue) {
});

if (!dbIssue) {
throw new Error(`there is no challenge for the updated issue ${issue.number}`);
throw errors.internalDependencyError(`there is no challenge for the updated issue ${issue.number}`);
}
return dbIssue;
}
@@ -109,6 +156,19 @@ async function assignUserAsRegistrant(topcoderUserId, challengeId) {
await topcoderApiHelper.addResourceToChallenge(challengeId, registrantBody);
}

/**
* re opens the issue
* @param {Object} event the event
* @param {Object} issue the issue
*/
async function reOpenIssue(event, issue) {
if (event.provider === 'github') {
await gitHubService.changeState(event.copilot, event.data.repository.name, issue.number, 'open');
} else {
await gitlabService.changeState(event.copilot, event.data.repository.id, issue.number, 'reopen');
}
}

/**
* removes the current assignee if user is not found in topcoder X mapping.
* user first need to sign up in Topcoder X
@@ -131,19 +191,17 @@ async function rollbackAssignee(event, assigneeUserId, issue, reOpen = false) {
await gitHubService.createComment(event.copilot, event.data.repository.name, issue.number, comment);
// un-assign the user from the ticket
await gitHubService.removeAssign(event.copilot, event.data.repository.name, issue.number, assigneeUsername);
if (reOpen) {
await gitHubService.changeState(event.copilot, event.data.repository.name, issue.number, 'open');
}
} else {
await gitlabService.createComment(event.copilot, event.data.repository.id, issue.number, comment);
// un-assign the user from the ticket
await gitlabService.removeAssign(event.copilot, event.data.repository.id, issue.number, assigneeUserId);
if (reOpen) {
await gitlabService.changeState(event.copilot, event.data.repository.id, issue.number, 'reopen');
}
}
if (reOpen) {
await reOpenIssue(event, issue);
}
}


/**
* Parse the comments from issue comment.
* @param {Object} comment the comment
@@ -193,14 +251,19 @@ async function handleIssueAssignment(event, issue) {
logger.debug(`Looking up TC handle of git user: ${assigneeUserId}`);
const userMapping = await userService.getTCUserName(event.provider, assigneeUserId);
if (userMapping && userMapping.topcoderUsername) {
const dbIssue = await ensureChallengeExists(issue);

logger.debug(`Getting the topcoder member ID for member name: ${userMapping.topcoderUsername}`);
const topcoderUserId = await topcoderApiHelper.getTopcoderMemberId(userMapping.topcoderUsername);
// Update the challenge
logger.debug(`Assigning user to challenge: ${userMapping.topcoderUsername}`);
assignUserAsRegistrant(topcoderUserId, dbIssue.challengeId);

let dbIssue;
try {
dbIssue = await ensureChallengeExists(issue);

logger.debug(`Getting the topcoder member ID for member name: ${userMapping.topcoderUsername}`);
const topcoderUserId = await topcoderApiHelper.getTopcoderMemberId(userMapping.topcoderUsername);
// Update the challenge
logger.debug(`Assigning user to challenge: ${userMapping.topcoderUsername}`);
assignUserAsRegistrant(topcoderUserId, dbIssue.challengeId);
} catch (err) {
handleEventGracefully(event, issue, err);
return;
}
const contestUrl = getUrlForChallengeId(dbIssue.challengeId);
const comment = `Contest ${contestUrl} has been updated - it has been assigned to ${userMapping.topcoderUsername}.`;
if (event.provider === 'github') {
@@ -256,21 +319,26 @@ async function handleIssueComment(event, issue) {
* @private
*/
async function handleIssueUpdate(event, issue) {
const dbIssue = await ensureChallengeExists(issue);
let dbIssue;
try {
dbIssue = await ensureChallengeExists(issue);

if (_.isMatch(dbIssue, issue)) {
// Title, body, prizes doesn't change, just ignore
logger.debug(`nothing changed for issue ${issue.number}`);
return;
}

if (_.isMatch(dbIssue, issue)) {
// Title, body, prizes doesn't change, just ignore
logger.debug(`nothing changed for issue ${issue.number}`);
// Update the challenge
await topcoderApiHelper.updateChallenge(dbIssue.challengeId, {
name: issue.title,
detailedRequirements: issue.body,
prizes: issue.prizes
});
} catch (e) {
await handleEventGracefully(event, issue, e);
return;
}

// Update the challenge
await topcoderApiHelper.updateChallenge(dbIssue.challengeId, {
name: issue.title,
detailedRequirements: issue.body,
prizes: issue.prizes
});

// Save
dbIssue.set({
title: issue.title,
@@ -298,75 +366,89 @@ async function handleIssueUpdate(event, issue) {
* @private
*/
async function handleIssueClose(event, issue) {
const dbIssue = await ensureChallengeExists(issue);
// if issue is closed without assignee then do nothing
if (!event.data.assignee.id) {
logger.debug(`This issue ${issue.number} doesn't have assignee so ignoring this event.`);
let dbIssue;
try {
dbIssue = await ensureChallengeExists(issue);
if (!event.paymentSuccessful) {
// if issue is closed without assignee then do nothing
if (!event.data.assignee.id) {
logger.debug(`This issue ${issue.number} doesn't have assignee so ignoring this event.`);
return;
}
// if issue has paid label don't process further
if (_.includes(event.data.issue.labels, config.PAID_ISSUE_LABEL)) {
logger.debug(`This issue ${issue.number} is already paid with challenge ${dbIssue.challengeId}`);
return;
}

logger.debug(`Looking up TC handle of git user: ${event.data.assignee.id}`);
const assigneeMember = await userService.getTCUserName(event.provider, event.data.assignee.id);

// no mapping is found for current assignee remove assign, re-open issue and make comment
// to assignee to login with Topcoder X
if (!(assigneeMember && assigneeMember.topcoderUsername)) {
await rollbackAssignee(event, event.data.assignee.id, issue, true);
}

// get project detail from db
const project = await getProjectDetail(issue, event);

logger.debug(`Getting the billing account ID for project ID: ${project.tcDirectId}`);
const accountId = await topcoderApiHelper.getProjectBillingAccountId(project.tcDirectId);

logger.debug(`assigning the billing account id ${accountId} to challenge`);

// adding assignees as well if it is missed/failed during update
// prize needs to be again set after adding billing account otherwise it won't let activate
const updateBody = {
billingAccountId: accountId,
prizes: issue.prizes
};
await topcoderApiHelper.updateChallenge(dbIssue.challengeId, updateBody);

logger.debug(`Getting the topcoder member ID for member name: ${assigneeMember.topcoderUsername}`);
const winnerId = await topcoderApiHelper.getTopcoderMemberId(assigneeMember.topcoderUsername);

logger.debug(`Getting the topcoder member ID for copilot name : ${event.copilot.topcoderUsername}`);
// get copilot tc user id
const copilotTopcoderUserId = await topcoderApiHelper.getTopcoderMemberId(event.copilot.topcoderUsername);

// role id 14 for copilot
const copilotResourceBody = {
roleId: 14,
resourceUserId: copilotTopcoderUserId,
phaseId: 0,
addNotification: true,
addForumWatch: true
};
await topcoderApiHelper.addResourceToChallenge(dbIssue.challengeId, copilotResourceBody);

// adding reg
await assignUserAsRegistrant(winnerId, dbIssue.challengeId);

// activate challenge
await topcoderApiHelper.activateChallenge(dbIssue.challengeId);

logger.debug(`close challenge with winner ${assigneeMember.topcoderUsername}(${winnerId})`);
await topcoderApiHelper.closeChallenge(dbIssue.challengeId, winnerId);
event.paymentSuccessful = true;
}
} catch (e) {
event.paymentSuccessful = event.paymentSuccessful === true; // if once paid shouldn't be false
await handleEventGracefully(event, issue, e, event.paymentSuccessful);
return;
}
// if issue has paid label don't process further
if (_.includes(event.data.issue.labels, config.PAID_ISSUE_LABEL)) {
logger.debug(`This issue ${issue.number} is already paid with challenge ${dbIssue.challengeId}`);
try {
logger.debug('update issue as paid');
if (event.provider === 'github') {
await gitHubService.markIssueAsPaid(event.copilot, event.data.repository.name, issue.number, dbIssue.challengeId);
} else {
await gitlabService.markIssueAsPaid(event.copilot, event.data.repository.id, issue.number, dbIssue.challengeId);
}
} catch (e) {
await handleEventGracefully(event, issue, e, event.paymentSuccessful);
return;
}

logger.debug(`Looking up TC handle of git user: ${event.data.assignee.id}`);
const assigneeMember = await userService.getTCUserName(event.provider, event.data.assignee.id);

// no mapping is found for current assignee remove assign, re-open issue and make comment
// to assignee to login with Topcoder X
if (!(assigneeMember && assigneeMember.topcoderUsername)) {
await rollbackAssignee(event, event.data.assignee.id, issue, true);
}

// get project detail from db
const project = await getProjectDetail(issue, event);

logger.debug(`Getting the billing account ID for project ID: ${project.tcDirectId}`);
const accountId = await topcoderApiHelper.getProjectBillingAccountId(project.tcDirectId);

logger.debug(`assigning the billing account id ${accountId} to challenge`);

// adding assignees as well if it is missed/failed during update
// prize needs to be again set after adding billing account otherwise it won't let activate
const updateBody = {
billingAccountId: accountId,
prizes: issue.prizes
};
await topcoderApiHelper.updateChallenge(dbIssue.challengeId, updateBody);

logger.debug(`Getting the topcoder member ID for member name: ${assigneeMember.topcoderUsername}`);
const winnerId = await topcoderApiHelper.getTopcoderMemberId(assigneeMember.topcoderUsername);

logger.debug(`Getting the topcoder member ID for copilot name : ${event.copilot.topcoderUsername}`);
// get copilot tc user id
const copilotTopcoderUserId = await topcoderApiHelper.getTopcoderMemberId(event.copilot.topcoderUsername);

// role id 14 for copilot
const copilotResourceBody = {
roleId: 14,
resourceUserId: copilotTopcoderUserId,
phaseId: 0,
addNotification: true,
addForumWatch: true
};
await topcoderApiHelper.addResourceToChallenge(dbIssue.challengeId, copilotResourceBody);

// adding reg
await assignUserAsRegistrant(winnerId, dbIssue.challengeId);

// activate challenge
await topcoderApiHelper.activateChallenge(dbIssue.challengeId);

logger.debug(`close challenge with winner ${assigneeMember.topcoderUsername}(${winnerId})`);
await topcoderApiHelper.closeChallenge(dbIssue.challengeId, winnerId);

logger.debug('update issue as paid');
if (event.provider === 'github') {
await gitHubService.markIssueAsPaid(event.copilot, event.data.repository.name, issue.number, dbIssue.challengeId);
} else {
await gitlabService.markIssueAsPaid(event.copilot, event.data.repository.id, issue.number, dbIssue.challengeId);
}
}


@@ -398,18 +480,22 @@ async function handleIssueCreate(event, issue) {
}// if existing found don't create a project
const projectId = project.tcDirectId;
logger.debug(`existing project was found with id ${projectId} for repository ${event.data.repository.full_name}`);

// Create a new challenge
issue.challengeId = await topcoderApiHelper.createChallenge({
name: issue.title,
projectId,
detailedRequirements: issue.body,
prizes: issue.prizes,
task: true
});

// Save
await Issue.create(issue);
try {
// Create a new challenge
issue.challengeId = await topcoderApiHelper.createChallenge({
name: issue.title,
projectId,
detailedRequirements: issue.body,
prizes: issue.prizes,
task: true
});

// Save
await Issue.create(issue);
} catch (e) {
await handleEventGracefully(event, issue, e);
return;
}

const contestUrl = getUrlForChallengeId(issue.challengeId);
const comment = `Contest ${contestUrl} has been created for this ticket.`;
@@ -498,10 +584,14 @@ process.schema = Joi.object().keys({
assignee: Joi.object().keys({
id: Joi.number().required().allow(null)
})
}).required()
}).required(),
retryCount: Joi.number().integer().default(0).optional(),
paymentSuccessful: Joi.boolean().default(false).optional()
});


module.exports = {
process
};

logger.buildService(module.exports);
3 changes: 3 additions & 0 deletions services/UserService.js
Original file line number Diff line number Diff line change
@@ -12,6 +12,7 @@
const Joi = require('joi');
const config = require('config');
const models = require('../models');
const logger = require('../utils/logger');

/**
* gets the tc handle for given git user id from a mapping captured by Topcoder x tool
@@ -93,3 +94,5 @@ module.exports = {
getTCUserName,
getRepositoryCopilot
};

logger.buildService(module.exports);
104 changes: 104 additions & 0 deletions utils/errors.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/*
* Copyright (c) 2018 TopCoder, Inc. All rights reserved.
*/

/**
* Define errors.
*
* @author veshu
* @version 1.0
*/
'use strict';

const _ = require('lodash');
const constants = require('../constants');

// the error class wrapper
class ProcessorError extends Error {
constructor(statusCode, message, errorAt) {
super(message);
this.name = this.constructor.name;
this.statusCode = statusCode;
this.errorAt = errorAt;
Error.captureStackTrace(this, this.constructor);
}
}

const errors = {};

/**
* Convert github api error.
* @param {Error} err the github api error
* @param {String} message the error message
* @returns {Error} converted error
*/
errors.convertGitHubError = function convertGitHubError(err, message) {
let resMsg = `${message}. ${err.message}.`;
const detail = _.get(err, 'response.body.message');
if (detail) {
resMsg += ` Detail: ${detail}`;
}
const apiError = new ProcessorError(
_.get(err, 'response.status', constants.SERVICE_ERROR_STATUS),
resMsg,
'github'
);
return apiError;
};

/**
* Convert gitlab api error.
* @param {Error} err the gitlab api error
* @param {String} message the error message
* @returns {Error} converted error
*/
errors.convertGitLabError = function convertGitLabError(err, message) {
let resMsg = `${message}. ${err.message}.`;
const detail = _.get(err, 'response.body.message');
if (detail) {
resMsg += ` Detail: ${detail}`;
}
const apiError = new ProcessorError(
err.status || _.get(err, 'response.status', constants.SERVICE_ERROR_STATUS),
resMsg,
'gitlab'
);
return apiError;
};

/**
* Convert topcoder api error.
* @param {Error} err the topcoder api error
* @param {String} message the error message
* @returns {Error} converted error
*/
errors.convertTopcoderApiError = function convertTopcoderApiError(err, message) {
let resMsg = `${message}`;
const detail = _.get(err, 'response.body.result.content');
if (detail) {
resMsg += ` Detail: ${detail}`;
}
const apiError = new ProcessorError(
err.status || _.get(err, 'response.body.result.status', constants.SERVICE_ERROR_STATUS),
resMsg,
'topcoder'
);
return apiError;
};

/**
* Convert internal error which needs to be handle gracefully.
* @param {String} message the error message
* @returns {Error} converted error
*/
errors.internalDependencyError = function internalDependencyError(message) {
const resMsg = `${message}`;
const apiError = new ProcessorError(
constants.SERVICE_ERROR_STATUS,
resMsg,
'processor'
);
return apiError;
};

module.exports = errors;
24 changes: 24 additions & 0 deletions utils/kafka.js
Original file line number Diff line number Diff line change
@@ -11,6 +11,7 @@
*/
'use strict';

const {promisify} = require('util');
const kafka = require('kafka-node');
const config = require('config');
const _ = require('lodash');
@@ -25,7 +26,10 @@ class Kafka {
this.consumer = new kafka.Consumer(this.client, [{topic: config.TOPIC, partition: config.PARTITION}], {autoCommit: true});
this.consumer.setOffset(config.TOPIC, 0, 0);
this.offset = new Offset(this.client);
this.producer = new kafka.Producer(this.client);
logger.info(`Connecting on topic: ${config.TOPIC}`);

this.sendAsync = promisify(this.producer.send).bind(this.producer);
}

run() {
@@ -66,6 +70,26 @@ class Kafka {
.catch(logger.error);
}
});
this.consumer.on('ready', () => {
logger.info('kafka consumer is ready.');
});
this.producer.on('ready', () => {
logger.info('kafka producer is ready.');

this.producer.createTopics([config.TOPIC], true, (err) => {
if (err) {
logger.error(`error in creating topic: ${config.TOPIC}, error: ${err.stack}`);
} else {
logger.info(`kafka topic: ${config.TOPIC} is ready`);
}
});
});
this.producer.on('error', (err) => {
logger.error(`kafka is not connected. ${err.stack}`);
});
}
send(message) {
return this.sendAsync([{topic: config.TOPIC, messages: message}]);
}
}

78 changes: 78 additions & 0 deletions utils/logger.js
Original file line number Diff line number Diff line change
@@ -9,8 +9,11 @@
* @version 1.0
*/
'use strict';
const util = require('util');
const _ = require('lodash');
const winston = require('winston');
const config = require('config');
const getParams = require('get-parameter-names');

const logger = new winston.Logger({
transports: [
@@ -33,5 +36,80 @@ logger.logFullError = function logFullError(err, signature) {
err.logged = true;
};

/**
* Remove invalid properties from the object and hide long arrays
* @param {Object} obj the object
* @returns {Object} the new object with removed properties
* @private
*/
function sanitizeObject(obj) {
try {
return JSON.parse(JSON.stringify(obj, (name, value) => {
// Array of field names that should not be logged
const removeFields = ['refreshToken', 'accessToken'];
if (_.includes(removeFields, name)) {
return '<removed>';
}
if (_.isArray(value) && value.length > 30) { // eslint-disable-line
return `Array(${value.length}`;
}
return value;
}));
} catch (e) {
return obj;
}
}

/**
* Convert array with arguments to object
* @param {Array} params the name of parameters
* @param {Array} arr the array with values
* @returns {Object} converted object
* @private
*/
function combineObject(params, arr) {
const ret = {};
_.forEach(arr, (arg, i) => {
ret[params[i]] = arg;
});
return ret;
}

/**
* Decorate all functions of a service and log debug information if DEBUG is enabled
* @param {Object} service the service
*/
logger.decorateWithLogging = function decorateWithLogging(service) {
if (config.LOG_LEVEL !== 'debug') {
return;
}
_.forEach(service, (method, name) => {
const params = method.params || getParams(method);
service[name] = async function serviceMethodWithLogging() {
logger.debug(`ENTER ${name}`);
logger.debug('input arguments');
const args = Array.prototype.slice.call(arguments); // eslint-disable-line
logger.debug(util.inspect(sanitizeObject(combineObject(params, args))));
try {
const result = await method.apply(this, arguments); // eslint-disable-line
logger.debug(`EXIT ${name}`);
logger.debug('output arguments');
logger.debug(util.inspect(sanitizeObject(result)));
return result;
} catch (e) {
logger.logFullError(e, name);
throw e;
}
};
});
};

/**
* Apply logger and validation decorators
* @param {Object} service the service to wrap
*/
logger.buildService = function buildService(service) {
logger.decorateWithLogging(service);
};

module.exports = logger;
161 changes: 96 additions & 65 deletions utils/topcoder-api-helper.js
Original file line number Diff line number Diff line change
@@ -25,6 +25,7 @@ const topcoderDevApiProjects = require('topcoder-dev-api-projects');
const topcoderDevApiChallenges = require('topcoder-dev-api-challenges');

const logger = require('./logger');
const errors = require('./errors');


if (config.TC_DEV_ENV) {
@@ -102,17 +103,20 @@ async function createProject(projectName) {
const projectBody = new topcoderApiProjects.ProjectRequestBody.constructFromObject({
projectName
});
const projectResponse = await new Promise((resolve, reject) => {
projectsApiInstance.directProjectsPost(projectBody, (err, res) => {
if (err) {
reject(err);
} else {
resolve(res);
}
try {
const projectResponse = await new Promise((resolve, reject) => {
projectsApiInstance.directProjectsPost(projectBody, (err, res) => {
if (err) {
reject(err);
} else {
resolve(res);
}
});
});
});

return _.get(projectResponse, 'result.content.projectId');
return _.get(projectResponse, 'result.content.projectId');
} catch (err) {
throw errors.convertTopcoderApiError(err, 'Failed to create project.');
}
}

/**
@@ -136,17 +140,21 @@ async function createChallenge(challenge) {
submissionEndsAt: end
}, challenge)
});
const challengeResponse = await new Promise((resolve, reject) => {
challengesApiInstance.saveDraftContest(challengeBody, (err, res) => {
if (err) {
reject(err);
} else {
resolve(res);
}
try {
const challengeResponse = await new Promise((resolve, reject) => {
challengesApiInstance.saveDraftContest(challengeBody, (err, res) => {
if (err) {
reject(err);
} else {
resolve(res);
}
});
});
});

return _.get(challengeResponse, 'result.content.id');
return _.get(challengeResponse, 'result.content.id');
} catch (err) {
throw errors.convertTopcoderApiError(err, 'Failed to create challenge.');
}
}

/**
@@ -161,18 +169,21 @@ async function updateChallenge(id, challenge) {
const challengeBody = new topcoderApiChallenges.UpdateChallengeBodyParam.constructFromObject({
param: challenge
});

await new Promise((resolve, reject) => {
challengesApiInstance.challengesIdPut(id, challengeBody, (err, res) => {
if (err) {
logger.error(err);
logger.debug(JSON.stringify(err));
reject(err);
} else {
resolve(res);
}
try {
await new Promise((resolve, reject) => {
challengesApiInstance.challengesIdPut(id, challengeBody, (err, res) => {
if (err) {
logger.error(err);
logger.debug(JSON.stringify(err));
reject(err);
} else {
resolve(res);
}
});
});
});
} catch (err) {
throw errors.convertTopcoderApiError(err, 'Failed to update challenge.');
}
}

/**
@@ -182,13 +193,17 @@ async function updateChallenge(id, challenge) {
async function activateChallenge(id) {
const apiKey = await getAccessToken();
logger.debug(`Activating challenge ${id}`);
await axios.post(`${projectsClient.basePath}/challenges/${id}/activate`, null, {
headers: {
authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json'
}
});
logger.debug(`Challenge ${id} is activated successfully.`);
try {
await axios.post(`${projectsClient.basePath}/challenges/${id}/activate`, null, {
headers: {
authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json'
}
});
logger.debug(`Challenge ${id} is activated successfully.`);
} catch (err) {
throw errors.convertTopcoderApiError(err, 'Failed to activate challenge.');
}
}

/**
@@ -199,13 +214,17 @@ async function activateChallenge(id) {
async function closeChallenge(id, winnerId) {
const apiKey = await getAccessToken();
logger.debug(`Closing challenge ${id}`);
await axios.post(`${projectsClient.basePath}/challenges/${id}/close?winnerId=${winnerId}`, null, {
headers: {
authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json'
}
});
logger.debug(`Challenge ${id} is closed successfully.`);
try {
await axios.post(`${projectsClient.basePath}/challenges/${id}/close?winnerId=${winnerId}`, null, {
headers: {
authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json'
}
});
logger.debug(`Challenge ${id} is closed successfully.`);
} catch (err) {
throw errors.convertTopcoderApiError(err, 'Failed to close challenge.');
}
}

/**
@@ -216,12 +235,16 @@ async function closeChallenge(id, winnerId) {
async function getProjectBillingAccountId(id) {
const apiKey = await getAccessToken();
logger.debug(`Getting project billing detail ${id}`);
const response = await axios.get(`${projectsClient.basePath}/direct/projects/${id}`, {
headers: {
authorization: `Bearer ${apiKey}`
}
});
return _.get(response, 'data.result.content.billingAccountIds[0]');
try {
const response = await axios.get(`${projectsClient.basePath}/direct/projects/${id}`, {
headers: {
authorization: `Bearer ${apiKey}`
}
});
return _.get(response, 'data.result.content.billingAccountIds[0]');
} catch (err) {
throw errors.convertTopcoderApiError(err, 'Failed to get billing detail for the project.');
}
}

/**
@@ -231,8 +254,12 @@ async function getProjectBillingAccountId(id) {
*/
async function getTopcoderMemberId(handle) {
bearer.apiKey = await getAccessToken();
const response = await axios.get(`${projectsClient.basePath}/members/${handle}`);
return _.get(response, 'data.result.content.userId');
try {
const response = await axios.get(`${projectsClient.basePath}/members/${handle}`);
return _.get(response, 'data.result.content.userId');
} catch (err) {
throw errors.convertTopcoderApiError(err, 'Failed to get topcoder member id.');
}
}

/**
@@ -243,22 +270,26 @@ async function getTopcoderMemberId(handle) {
async function addResourceToChallenge(id, resource) {
bearer.apiKey = await getAccessToken();
logger.debug(`adding resource to challenge ${id}`);
await new Promise((resolve, reject) => {
challengesApiInstance.challengesIdResourcesPost(id, resource, (err, res) => {
if (err) {
if (_.get(JSON.parse(_.get(err, 'response.text')), 'result.content')
=== `User ${resource.resourceUserId} with role ${resource.roleId} already exists`) {
resolve();
try {
await new Promise((resolve, reject) => {
challengesApiInstance.challengesIdResourcesPost(id, resource, (err, res) => {
if (err) {
if (_.get(JSON.parse(_.get(err, 'response.text')), 'result.content')
=== `User ${resource.resourceUserId} with role ${resource.roleId} already exists`) {
resolve();
} else {
logger.error(JSON.stringify(err));
reject(err);
}
} else {
logger.error(JSON.stringify(err));
reject(err);
logger.debug(`resource is added to challenge ${id} successfully.`);
resolve(res);
}
} else {
logger.debug(`resource is added to challenge ${id} successfully.`);
resolve(res);
}
});
});
});
} catch (err) {
throw errors.convertTopcoderApiError(err, 'Failed to add resource to the challenge.');
}
}

module.exports = {