diff --git a/.eslintrc b/.eslintrc index a16e9ea..f85c154 100644 --- a/.eslintrc +++ b/.eslintrc @@ -7,7 +7,7 @@ "jsdoc" ], "parserOptions": { - "ecmaVersion": 8, + "ecmaVersion": 2023, "ecmaFeatures": { "experimentalObjectRestSpread": true } @@ -45,6 +45,8 @@ "functions": "ignore" } ], + "import/no-unresolved": 0, + "max-params": 0, "max-lines": 0, "max-statements": 0, "valid-jsdoc": 0 diff --git a/config/default.js b/config/default.js index 68d4216..d62967a 100644 --- a/config/default.js +++ b/config/default.js @@ -80,5 +80,6 @@ module.exports = { GITLAB_REFRESH_TOKEN_BEFORE_EXPIRATION: 300, GITLAB_CLIENT_ID: process.env.GITLAB_CLIENT_ID, GITLAB_CLIENT_SECRET: process.env.GITLAB_CLIENT_SECRET, - GITLAB_OWNER_USER_CALLBACK_URL: process.env.GITLAB_OWNER_USER_CALLBACK_URL + GITLAB_OWNER_USER_CALLBACK_URL: process.env.GITLAB_OWNER_USER_CALLBACK_URL, + GITLAB_GUEST_USER_CALLBACK_URL: process.env.GITLAB_GUEST_USER_CALLBACK_URL }; diff --git a/constants.js b/constants.js index cb32c76..dcdd16f 100644 --- a/constants.js +++ b/constants.js @@ -19,7 +19,8 @@ const USER_TYPES = { // The user roles const USER_ROLES = { - OWNER: 'owner' + OWNER: 'owner', + GUEST: 'guest' }; // The challenge status diff --git a/models/User.js b/models/User.js index dc3ca69..7237dfe 100755 --- a/models/User.js +++ b/models/User.js @@ -49,7 +49,9 @@ const schema = new Schema({ // gitlab token data accessToken: {type: String, required: false}, accessTokenExpiration: {type: Date, required: false}, - refreshToken: {type: String, required: false} + refreshToken: {type: String, required: false}, + lockId: {type: String, required: false}, + lockExpiration: {type: Date, required: false} }); module.exports = schema; diff --git a/package-lock.json b/package-lock.json index 74bde4c..929ac63 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "@gitbeaker/rest": "^39.12.0", + "@gitbeaker/rest": "^39.13.0", "@octokit/rest": "^18.9.0", "axios": "^0.19.0", "circular-json": "^0.5.7", @@ -46,7 +46,8 @@ "uuid": "^3.3.2" }, "engines": { - "node": "~8.6.0" + "node": ">=20", + "npm": ">=9" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -204,11 +205,11 @@ } }, "node_modules/@gitbeaker/core": { - "version": "39.12.0", - "resolved": "https://registry.npmjs.org/@gitbeaker/core/-/core-39.12.0.tgz", - "integrity": "sha512-c/LQl+UI+rXjXx+P+kLrdTOZnyD/RdhRWnkuBE8b8/dwfvnJ3ANEv1WBZhVJpTwn6miHlurERiOeLmKeQJ+J6w==", + "version": "39.13.0", + "resolved": "https://registry.npmjs.org/@gitbeaker/core/-/core-39.13.0.tgz", + "integrity": "sha512-L+QhR1xQiWPSLjfzYuUgs3b+YLgYBUIMjWCYdd+31HzCiqMC80nBRTqPx4vH3A4rtYciZM5EL3xcl6txjVZ0EQ==", "dependencies": { - "@gitbeaker/requester-utils": "^39.12.0", + "@gitbeaker/requester-utils": "^39.13.0", "qs": "^6.11.2", "xcase": "^2.0.1" }, @@ -231,9 +232,9 @@ } }, "node_modules/@gitbeaker/requester-utils": { - "version": "39.12.0", - "resolved": "https://registry.npmjs.org/@gitbeaker/requester-utils/-/requester-utils-39.12.0.tgz", - "integrity": "sha512-0016Xnt6xIO3kSUuJ/mcXyN8LWUMpqAmLFbXZWMzJ9YZpQHX9vPKsrFHz4/P9Dh9eWKoAnkjEXEf/F6w1gX6Dg==", + "version": "39.13.0", + "resolved": "https://registry.npmjs.org/@gitbeaker/requester-utils/-/requester-utils-39.13.0.tgz", + "integrity": "sha512-TKzbN9znKCjtAvHbksGian3/qS2CT0PL7RKLm3arBQB3WlRz/JhvekL/X773VVEOi/AZt45T5DlZjFHGtTj3Eg==", "dependencies": { "qs": "^6.11.2", "xcase": "^2.0.1" @@ -257,12 +258,12 @@ } }, "node_modules/@gitbeaker/rest": { - "version": "39.12.0", - "resolved": "https://registry.npmjs.org/@gitbeaker/rest/-/rest-39.12.0.tgz", - "integrity": "sha512-PILr042hCuB/A/QE7IGQ0ADTMN1e8lvehEfZEQEp78aWlqyNFmi6IUZqL6q8v3Stu6WRKSRcMCOidY83hiCSsA==", + "version": "39.13.0", + "resolved": "https://registry.npmjs.org/@gitbeaker/rest/-/rest-39.13.0.tgz", + "integrity": "sha512-pGqNLUX32BWHKbx2M8UsRN7irHPq730m0YFPlzeCBGi/5OYDapoIiGJeIYS12Dc8BJBiHJahVjG5pNnMBGfDBg==", "dependencies": { - "@gitbeaker/core": "^39.12.0", - "@gitbeaker/requester-utils": "^39.12.0" + "@gitbeaker/core": "^39.13.0", + "@gitbeaker/requester-utils": "^39.13.0" }, "engines": { "node": ">=18.0.0" diff --git a/package.json b/package.json index 974ad42..5d74f99 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "direct-connect-migration": "node scripts/direct-connect-migration.js" }, "engines": { - "node": "~8.6.0" + "node": ">=20", + "npm": ">=9" }, "repository": { "type": "git", @@ -26,7 +27,7 @@ }, "homepage": "https://gitlab.com/luettich/processor#README", "dependencies": { - "@gitbeaker/rest": "^39.12.0", + "@gitbeaker/rest": "^39.13.0", "@octokit/rest": "^18.9.0", "axios": "^0.19.0", "circular-json": "^0.5.7", diff --git a/services/EventService.js b/services/EventService.js index d2cbece..3f66c9f 100644 --- a/services/EventService.js +++ b/services/EventService.js @@ -14,7 +14,7 @@ const logger = require('../utils/logger'); const models = require('../models'); const dbHelper = require('../utils/db-helper'); const gitHubService = require('./GithubService'); -const gitlabService = require('./GitlabService'); +const GitlabService = require('./GitlabService'); const timeoutMapper = {}; @@ -27,7 +27,8 @@ async function reOpenIssue(event, issue) { if (event.provider === 'github') { await gitHubService.changeState(event.copilot, event.data.repository.full_name, issue.number, 'open'); } else if (event.provider === 'gitlab') { - await gitlabService.changeState(event.copilot, event.data.repository.id, issue.number, 'reopen'); + const gitlabService = await GitlabService.create(event.copilot); + await gitlabService.changeState(event.data.repository.id, issue.number, 'reopen'); } } @@ -37,7 +38,7 @@ async function reOpenIssue(event, issue) { * @param {Object} data the issue data or the copilot payment data * @param {Object} err the error */ -async function handleEventGracefully(event, data, err) { +async function handleEventGracefully(event, data, err) { // eslint-disable-line complexity if (err.errorAt === 'topcoder' || err.errorAt === 'processor') { event.retryCount = _.toInteger(event.retryCount); let keyName = ''; @@ -64,7 +65,7 @@ async function handleEventGracefully(event, data, err) { if (event.retryCount === config.RETRY_COUNT) { // Clear out the kafka queue of any queued messages (assignment, label changes, etc...) const timeoutsToClear = timeoutMapper[keyName]; - for (let i = 0; i < timeoutsToClear.length; i++) { // eslint-disable-line no-restricted-syntax + for (let i = 0; i < timeoutsToClear.length; i++) { // eslint-disable-line no-restricted-syntax clearTimeout(timeoutsToClear[i]); } let comment = `[${err.statusCode}]: ${err.message}`; @@ -95,7 +96,8 @@ async function handleEventGracefully(event, data, err) { if (event.provider === 'github') { await gitHubService.createComment(event.copilot, event.data.repository.full_name, data.number, comment); } else if (event.provider === 'gitlab') { - await gitlabService.createComment(event.copilot, event.data.repository.id, data.number, comment); + const gitlabService = await GitlabService.create(event.copilot); + await gitlabService.createComment(event.data.repository.id, data.number, comment); } if (event.event === 'issue.closed') { diff --git a/services/GitlabService.js b/services/GitlabService.js index c58449f..1fce8ed 100644 --- a/services/GitlabService.js +++ b/services/GitlabService.js @@ -8,8 +8,8 @@ * @author TCSCODER * @version 1.0 */ - const config = require('config'); +const uuid = require('uuid').v4; const _ = require('lodash'); const Joi = require('joi'); const {Gitlab} = require('@gitbeaker/rest'); @@ -22,404 +22,495 @@ const helper = require('../utils/helper'); const dbHelper = require('../utils/db-helper'); const request = superagentPromise(superagent, Promise); + // milliseconds per second const MS_PER_SECOND = 1000; -const copilotUserSchema = Joi.object().keys({ +const LOCK_TTL_SECONDS = 20; + +const MAX_RETRY_COUNT = 30; + +const COOLDOWN_TIME = 1000; + +/** + * A schema for a Gitlab user, as stored in the TCX database. + * @typedef {Object} User + * @property {String} accessToken the access token + * @property {Date} accessTokenExpiration the access token expiration date + * @property {String} refreshToken the refresh token + * @property {Number} userProviderId the user provider id + * @property {String} topcoderUsername the topcoder username + * @property {String} username the username + * @property {String} type the type + * @property {String} id the id + * @property {String} role the role + */ + +const USER_SCHEMA = Joi.object().keys({ accessToken: Joi.string().required(), accessTokenExpiration: Joi.date().required(), refreshToken: Joi.string().required(), userProviderId: Joi.number().required(), - topcoderUsername: Joi.string() + topcoderUsername: Joi.string(), + username: Joi.string().optional(), + type: Joi.string().valid('gitlab').required(), + id: Joi.string().optional(), + role: Joi.string().valid('owner', 'guest').required(), + lockId: Joi.string().optional(), + lockExpiration: Joi.date().optional() }).required(); /** - * authenticate the gitlab using access token - * @param {String} accessToken the access token of copilot - * @private + * @typedef {Object} ProjectWithId + * @property {Number} id the project id */ -async function _authenticate(accessToken) { - try { - const gitlab = new Gitlab({ - host: config.GITLAB_API_BASE_URL, - oauthToken: accessToken - }); - return gitlab; - } catch (err) { - throw errors.handleGitLabError(err, 'Failed to during authenticate to Github using access token of copilot.'); - } -} -/** - * Removes assignees from issue - * @param {import('@gitbeaker/core').Gitlab} gitlab the gitlab instance - * @param {Number} projectId the project id - * @param {Number} issueId the issue number - * @param {Array} assignees the users to remove - * @private - */ -async function _removeAssignees(gitlab, projectId, issueId, assignees) { - try { - const issue = await gitlab.Issues.show(issueId, {projectId}); - const oldAssignees = _.difference(issue.assignee_ids, assignees); - await gitlab.Issues.edit(projectId, issueId, {assigneeIds: oldAssignees}); - } catch (err) { - throw errors.handleGitLabError(err, 'Error occurred during remove assignees from issue.'); - } -} - -/** - * Get gitlab issue url - * @param {String} repoPath the repo path - * @param {Number} issueId the issue number - * @returns {String} the url - * @private - */ -function _getIssueUrl(repoPath, issueId) { - return `https://gitlab.com/${repoPath}/issues/${issueId}`; -} +const PROJECT_WITH_ID_SCHEMA = Joi.object().keys({ + id: Joi.number().positive().required() +}).unknown(true).required(); -/** - * creates the comments on gitlab issue - * @param {Object} copilot the copilot - * @param {Object} project the project object - * @param {Number} issueId the issue number - * @param {string} body the comment body text - */ -async function createComment(copilot, project, issueId, body) { - const projectId = project.id; - Joi.attempt({copilot, projectId, issueId, body}, createComment.schema); - const refreshedCopilot = await _refreshGitlabUserAccessToken(copilot); - const gitlab = await _authenticate(refreshedCopilot.accessToken); - try { - body = helper.prepareAutomatedComment(body, copilot); - await gitlab.IssueNotes.create(projectId, issueId, body); - } catch (err) { - throw errors.handleGitLabError(err, 'Error occurred during creating comment on issue.', copilot.topcoderUsername, _getIssueUrl(project.full_name, issueId)); - } - logger.debug(`Gitlab comment is added on issue with message: "${body}"`); -} +class GitlabService { + /** @type {User} */ + #user = null; -createComment.schema = { - copilot: copilotUserSchema, - projectId: Joi.number().positive().required(), - issueId: Joi.number().positive().required(), - body: Joi.string().required() -}; + /** @type {Gitlab} */ + #gitlab = null; -/** - * updates the title of gitlab issue - * @param {Object} copilot the copilot - * @param {Object} project the project object - * @param {Number} issueId the issue number - * @param {string} title new title - */ -async function updateIssue(copilot, project, issueId, title) { - const projectId = project.id; - Joi.attempt({copilot, projectId, issueId, title}, updateIssue.schema); - const refreshedCopilot = await _refreshGitlabUserAccessToken(copilot); - const gitlab = await _authenticate(refreshedCopilot.accessToken); - try { - await gitlab.Issues.edit(projectId, issueId, {title}); - } catch (err) { - throw errors.handleGitLabError(err, 'Error occurred during updating issue.', copilot.topcoderUsername, _getIssueUrl(project.full_name, issueId)); + constructor(user) { + if (!user) { + throw new Error('User is required.'); + } + Joi.attempt(user, USER_SCHEMA); + this.#user = user; } - logger.debug(`Gitlab issue title is updated for issue number ${issueId}`); -} - -updateIssue.schema = { - copilot: copilotUserSchema, - projectId: Joi.number().positive().required(), - issueId: Joi.number().positive().required(), - title: Joi.string().required() -}; -/** - * Assigns the issue to user login - * @param {Object} copilot the copilot - * @param {Object} project the project object - * @param {Number} issueId the issue number - * @param {Number} userId the user id of assignee - */ -async function assignUser(copilot, project, issueId, userId) { - const projectId = project.id; - Joi.attempt({copilot, projectId, issueId, userId}, assignUser.schema); - const refreshedCopilot = await _refreshGitlabUserAccessToken(copilot); - const gitlab = await _authenticate(refreshedCopilot.accessToken); - try { - const issue = await gitlab.Issues.show(issueId, {projectId}); - const oldAssignees = _.without(issue.assignees.map((a) => a.id), userId); - if (oldAssignees && oldAssignees.length > 0) { - await _removeAssignees(gitlab, projectId, issueId, oldAssignees); + /** + * Helper method for initializing a GitlabService instance with an active + * access token. + * @param {User} user the user + * @returns {Promise} the GitlabService instance + */ + static async create(user) { + const svc = new GitlabService(user); + try { + await svc.refreshAccessToken(); + svc.#gitlab = new Gitlab({ + host: config.GITLAB_API_BASE_URL, + oauthToken: user.accessToken + }); + return svc; + } catch (err) { + throw errors.handleGitLabError(err, 'Authentication failed for Gitlab user'); } - await gitlab.Issues.edit(projectId, issueId, {assigneeIds: [userId]}); - } catch (err) { - throw errors.handleGitLabError(err, 'Error occurred during assigning issue user.', copilot.topcoderUsername, _getIssueUrl(project.full_name, issueId)); } - logger.debug(`Gitlab issue with number ${issueId} is assigned to ${issueId}`); -} - -assignUser.schema = { - copilot: copilotUserSchema, - projectId: Joi.number().positive().required(), - issueId: Joi.number().positive().required(), - userId: Joi.number().required() -}; -/** - * Removes an assignee from the issue - * @param {Object} copilot the copilot - * @param {Object} project the project object - * @param {Number} issueId the issue number - * @param {Number} userId the user id of assignee to remove - */ -async function removeAssign(copilot, project, issueId, userId) { - const projectId = project.id; - Joi.attempt({copilot, projectId, issueId, userId}, removeAssign.schema); - const refreshedCopilot = await _refreshGitlabUserAccessToken(copilot); - const gitlab = await _authenticate(refreshedCopilot.accessToken); - await _removeAssignees(gitlab, projectId, issueId, [userId]); - logger.debug(`Gitlab user ${userId} is unassigned from issue number ${issueId}`); -} -removeAssign.schema = assignUser.schema; + /** + * Refresh the user access token if needed + */ + async refreshAccessToken() { + const lockId = uuid().replace(/-/g, ''); + let lockedUser; + let tries = 0; + try { + // eslint-disable-next-line no-constant-condition, no-restricted-syntax + while ((tries < MAX_RETRY_COUNT) && !(lockedUser && lockedUser.lockId === lockId)) { + logger.debug(`[Lock ID: ${lockId}][Attempt #${tries + 1}] Acquiring lock on user ${this.#user.username}.`); + lockedUser = await dbHelper.acquireLockOnUser(this.#user.id, lockId, LOCK_TTL_SECONDS * MS_PER_SECOND); + await new Promise((resolve) => setTimeout(resolve, COOLDOWN_TIME)); + tries += 1; + } + if (!lockedUser) { + throw new Error(`Failed to acquire lock on user ${this.#user.id} after ${tries} attempts.`); + } + logger.debug(`[Lock ID: ${lockId}] Acquired lock on user ${this.#user.id}.`); + if (lockedUser.accessTokenExpiration && new Date().getTime() > lockedUser.accessTokenExpiration.getTime() - + (config.GITLAB_REFRESH_TOKEN_BEFORE_EXPIRATION * MS_PER_SECOND)) { + logger.debug(`[Lock ID: ${lockId}] Refreshing access token for user ${this.#user.id}.`); + const query = { + client_id: config.GITLAB_CLIENT_ID, + client_secret: config.GITLAB_CLIENT_SECRET, + refresh_token: lockedUser.refreshToken, + grant_type: 'refresh_token' + }; + const refreshTokenResult = await request + .post(`${config.GITLAB_API_BASE_URL}/oauth/token`) + .query(query) + .end(); + // save user token data + const expiresIn = refreshTokenResult.body.expires_in || config.GITLAB_ACCESS_TOKEN_DEFAULT_EXPIRATION; + const updates = { + accessToken: refreshTokenResult.body.access_token, + accessTokenExpiration: new Date(new Date().getTime() + expiresIn * MS_PER_SECOND), + refreshToken: refreshTokenResult.body.refresh_token + }; + _.assign(lockedUser, updates); + await dbHelper.update(models.User, lockedUser.id, updates); + } + return lockedUser; + } finally { + if (lockedUser) { + logger.debug(`[Lock ID: ${lockId}] Releasing lock on user ${this.#user.id}.`); + await dbHelper.releaseLockOnUser(this.#user.id, lockId); + } + } + } -/** - * Gets the user name by user id - * @param {Object} copilot the copilot - * @param {Number} userId the user id - * @returns {string} the username if found else null - */ -async function getUsernameById(copilot, userId) { - Joi.attempt({copilot, userId}, getUsernameById.schema); - const refreshedCopilot = await _refreshGitlabUserAccessToken(copilot); - const gitlab = await _authenticate(refreshedCopilot.accessToken); - const user = await gitlab.Users.show(userId); - return user ? user.username : null; -} + /** + * Removes assignees from issue + * @param {Number} projectId the project id + * @param {Number} issueId the issue number + * @param {Array} assignees the users to remove + * @private + */ + async #removeAssignees(projectId, issueId, assignees) { + try { + const issue = await this.#gitlab.Issues.show(issueId, {projectId}); + const oldAssignees = _.difference(issue.assignee_ids, assignees); + await this.#gitlab.Issues.edit(projectId, issueId, {assigneeIds: oldAssignees}); + } catch (err) { + throw errors.handleGitLabError(err, 'Error occurred during remove assignees from issue.'); + } + } -getUsernameById.schema = { - copilot: copilotUserSchema, - userId: Joi.number().required() -}; + /** + * Get gitlab issue url + * @param {String} repoPath the repo path + * @param {Number} issueId the issue number + * @returns {String} the url + * @private + */ + #getIssueUrl(repoPath, issueId) { + return `https://gitlab.com/${repoPath}/issues/${issueId}`; + } -/** - * Gets the user id by username - * @param {Object} copilot the copilot - * @param {string} login the username - * @returns {Number} the user id if found else null - */ -async function getUserIdByLogin(copilot, login) { - Joi.attempt({copilot, login}, getUserIdByLogin.schema); - const refreshedCopilot = await _refreshGitlabUserAccessToken(copilot); - const gitlab = await _authenticate(refreshedCopilot.accessToken); - const user = await gitlab.Users.all({username: login}); - return user.length ? user[0].id : null; -} + /** + * creates the comments on gitlab issue + * @param {ProjectWithId} project the project object + * @param {Number} issueId the issue number + * @param {String} body the comment body text + */ + async createComment(project, issueId, body) { + Joi.attempt({project, issueId, body}, { + project: PROJECT_WITH_ID_SCHEMA, + issueId: Joi.number().positive().required(), + body: Joi.string().required() + }); + const projectId = project.id; + try { + body = helper.prepareAutomatedComment(body, this.#user); + await this.#gitlab.IssueNotes.create(projectId, issueId, body); + } catch (err) { + throw errors.handleGitLabError( + err, + 'Error occurred during creating comment on issue.', + this.#user.topcoderUsername, + this.#getIssueUrl(project.full_name, issueId) + ); + } + logger.debug(`Gitlab comment is added on issue with message: "${body}"`); + } -getUserIdByLogin.schema = { - copilot: copilotUserSchema, - login: Joi.string().required() -}; + /** + * updates the title of gitlab issue + * @param {ProjectWithId} project the project object + * @param {Number} issueId the issue number + * @param {String} title new title + */ + async updateIssue(project, issueId, title) { + Joi.attempt({project, issueId, title}, { + project: PROJECT_WITH_ID_SCHEMA, + issueId: Joi.number().positive().required(), + title: Joi.string().required() + }); + const projectId = project.id; + try { + await this.#gitlab.Issues.edit(projectId, issueId, {title}); + } catch (err) { + throw errors.handleGitLabError( + err, + 'Error occurred during updating issue.', + this.#user.topcoderUsername, + this.#getIssueUrl(project.full_name, issueId) + ); + } + logger.debug(`Gitlab issue title is updated for issue number ${issueId}`); + } -/** - * updates the gitlab issue as paid and fix accepted - * @param {Object} copilot the copilot - * @param {Object} project the project object - * @param {Number} issueId the issue number - * @param {String} challengeUUID the challenge uuid - * @param {Array} existLabels the issue labels - * @param {String} winner the winner topcoder handle - * @param {Boolean} createCopilotPayments the option to create copilot payments or not - */ -async function markIssueAsPaid(copilot, project, issueId, challengeUUID, existLabels, winner, createCopilotPayments) { // eslint-disable-line max-params - const projectId = project.id; - Joi.attempt({copilot, projectId, issueId, challengeUUID, existLabels, winner, createCopilotPayments}, markIssueAsPaid.schema); - const refreshedCopilot = await _refreshGitlabUserAccessToken(copilot); - const gitlab = await _authenticate(refreshedCopilot.accessToken); - const labels = _(existLabels).filter((i) => i !== config.FIX_ACCEPTED_ISSUE_LABEL) - .push(config.FIX_ACCEPTED_ISSUE_LABEL, config.PAID_ISSUE_LABEL).value(); - try { - await gitlab.Issues.edit(projectId, issueId, {labels}); - let commentMessage = ''; - - commentMessage += `Payment task has been updated: ${config.TC_URL}/challenges/${challengeUUID}\n\n`; - commentMessage += '*Payments Complete*\n\n'; - commentMessage += `Winner: ${winner}\n\n`; - if (createCopilotPayments) { - commentMessage += `Copilot: ${copilot.topcoderUsername}\n\n`; + /** + * Assigns the issue to user login + * @param {ProjectWithId} project the project object + * @param {Number} issueId the issue number + * @param {Number} userId the user id of assignee + */ + async assignUser(project, issueId, userId) { + Joi.attempt({project, issueId, userId}, { + project: PROJECT_WITH_ID_SCHEMA, + issueId: Joi.number().positive().required(), + userId: Joi.number().required() + }); + const projectId = project.id; + try { + const issue = await this.#gitlab.Issues.show(issueId, {projectId}); + const oldAssignees = _.without(issue.assignees.map((a) => a.id), userId); + if (oldAssignees && oldAssignees.length > 0) { + await this.#removeAssignees(projectId, issueId, oldAssignees); + } + await this.#gitlab.Issues.edit(projectId, issueId, {assigneeIds: [userId]}); + } catch (err) { + throw errors.handleGitLabError( + err, + 'Error occurred during assigning issue user.', + this.#user.topcoderUsername, + this.#getIssueUrl(project.full_name, issueId) + ); } - commentMessage += `Challenge \`${challengeUUID}\` has been paid and closed.`; + logger.debug(`Gitlab issue with number ${issueId} is assigned to ${issueId}`); + } - const body = helper.prepareAutomatedComment(commentMessage, copilot); - await gitlab.IssueNotes.create(projectId, issueId, body); - } catch (err) { - throw errors.handleGitLabError(err, 'Error occurred during updating issue as paid.', copilot.topcoderUsername, _getIssueUrl(project.full_name, issueId)); + /** + * Removes an assignee from the issue + * @param {ProjectWithId} project the project object + * @param {Number} issueId the issue number + * @param {Number} userId the user id of assignee to remove + */ + async removeAssign(project, issueId, userId) { + Joi.attempt({project, issueId, userId}, { + project: PROJECT_WITH_ID_SCHEMA, + issueId: Joi.number().positive().required(), + userId: Joi.number().required() + }); + const projectId = project.id; + await this.#removeAssignees(projectId, issueId, [userId]); + logger.debug(`Gitlab user ${userId} is unassigned from issue number ${issueId}`); } - logger.debug(`Gitlab issue is updated for as paid and fix accepted for ${issueId}`); -} -markIssueAsPaid.schema = { - copilot: copilotUserSchema, - projectId: Joi.number().positive().required(), - issueId: Joi.number().positive().required(), - challengeUUID: Joi.string().required(), - existLabels: Joi.array().items(Joi.string()).required(), - winner: Joi.string().required(), - createCopilotPayments: Joi.boolean().default(false).optional() -}; + /** + * Gets the user name by user id + * @param {Number} userId the user id + * @returns {string} the username if found else null + */ + async getUsernameById(userId) { + Joi.attempt({userId}, {userId: Joi.number().required()}); + const user = await this.#gitlab.Users.show(userId); + return user ? user.username : null; + } -/** - * change the state of gitlab issue - * @param {Object} copilot the copilot - * @param {Object} project the project object - * @param {Number} issueId the issue issue id - * @param {string} state new state - */ -async function changeState(copilot, project, issueId, state) { - const projectId = project.id; - Joi.attempt({copilot, projectId, issueId, state}, changeState.schema); - const refreshedCopilot = await _refreshGitlabUserAccessToken(copilot); - const gitlab = await _authenticate(refreshedCopilot.accessToken); - try { - await gitlab.Issues.edit(projectId, issueId, {stateEvent: state}); - } catch (err) { - throw errors.handleGitLabError(err, 'Error occurred during updating status of issue.', copilot.topcoderUsername, _getIssueUrl(project.full_name, issueId)); + /** + * Gets the user id by username + * @param {String} login the username + * @returns {Number} the user id if found else null + */ + async getUserIdByLogin(login) { + Joi.attempt({login}, {login: Joi.string().required()}); + const user = await this.#gitlab.Users.all({username: login}); + return user.length ? user[0].id : null; } - logger.debug(`Gitlab issue state is updated to '${state}' for issue number ${issueId}`); -} -changeState.schema = { - copilot: copilotUserSchema, - projectId: Joi.number().positive().required(), - issueId: Joi.number().positive().required(), - state: Joi.string().required() -}; + /** updates the gitlab issue as paid and fix accepted + * @param {ProjectWithId} project the project object + * @param {Number} issueId the issue number + * @param {String} challengeUUID the challenge uuid + * @param {Array} existLabels the issue labels + * @param {String} winner the winner topcoder handle + * @param {Boolean} createCopilotPayments the option to create copilot payments or not + */ + async markIssueAsPaid(project, issueId, challengeUUID, existLabels, winner, createCopilotPayments) { + Joi.attempt({project, issueId, challengeUUID, existLabels, winner, createCopilotPayments}, { + project: PROJECT_WITH_ID_SCHEMA, + issueId: Joi.number().positive().required(), + challengeUUID: Joi.string().required(), + existLabels: Joi.array().items(Joi.string()).required(), + winner: Joi.string().required(), + createCopilotPayments: Joi.boolean().default(false).optional() + }); + const projectId = project.id; + const labels = _(existLabels).filter((i) => i !== config.FIX_ACCEPTED_ISSUE_LABEL) + .push(config.FIX_ACCEPTED_ISSUE_LABEL, config.PAID_ISSUE_LABEL).value(); + try { + await this.#gitlab.Issues.edit(projectId, issueId, {labels}); + let commentMessage = ''; + + commentMessage += `Payment task has been updated: ${config.TC_URL}/challenges/${challengeUUID}\n\n`; + commentMessage += '*Payments Complete*\n\n'; + commentMessage += `Winner: ${winner}\n\n`; + if (createCopilotPayments) { + commentMessage += `Copilot: ${this.#user.topcoderUsername}\n\n`; + } + commentMessage += `Challenge \`${challengeUUID}\` has been paid and closed.`; + + const body = helper.prepareAutomatedComment(commentMessage, this.#user); + await this.#gitlab.IssueNotes.create(projectId, issueId, body); + } catch (err) { + throw errors.handleGitLabError( + err, + 'Error occurred during updating issue as paid.', + this.#user.topcoderUsername, + this.#getIssueUrl(project.full_name, issueId) + ); + } + logger.debug(`Gitlab issue is updated for as paid and fix accepted for ${issueId}`); + } -/** - * updates the gitlab issue with new labels - * @param {Object} copilot the copilot - * @param {Object} project the project object - * @param {Number} issueId the issue issue id - * @param {Number} labels the labels - */ -async function addLabels(copilot, project, issueId, labels) { - const projectId = project.id; - Joi.attempt({copilot, projectId, issueId, labels}, addLabels.schema); - const refreshedCopilot = await _refreshGitlabUserAccessToken(copilot); - const gitlab = await _authenticate(refreshedCopilot.accessToken); - try { - await gitlab.Issues.edit(projectId, issueId, {labels: _.join(labels, ',')}); - } catch (err) { - throw errors.handleGitLabError(err, 'Error occurred during adding label in issue.', copilot.topcoderUsername, _getIssueUrl(project.full_name, issueId)); + /** + * change the state of gitlab issue + * @param {ProjectWithId} project the project object + * @param {Number} issueId the issue issue id + * @param {string} state new state + */ + async changeState(project, issueId, state) { + Joi.attempt({project, issueId, state}, { + project: PROJECT_WITH_ID_SCHEMA, + issueId: Joi.number().positive().required(), + state: Joi.string().required() + }); + const projectId = project.id; + try { + await this.#gitlab.Issues.edit(projectId, issueId, {stateEvent: state}); + } catch (err) { + throw errors.handleGitLabError( + err, + 'Error occurred during updating status of issue.', + this.#user.topcoderUsername, + this.#getIssueUrl(project.full_name, issueId) + ); + } + logger.debug(`Gitlab issue state is updated to '${state}' for issue number ${issueId}`); } - 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() -}; + /** + * updates the gitlab issue with new labels + * @param {ProjectWithId} project the project object + * @param {Number} issueId the issue issue id + * @param {Number} labels the labels + */ + async addLabels(project, issueId, labels) { + Joi.attempt({project, issueId, labels}, { + project: PROJECT_WITH_ID_SCHEMA, + issueId: Joi.number().positive().required(), + labels: Joi.array().items(Joi.string()).required() + }); + const projectId = project.id; + try { + await this.#gitlab.Issues.edit(projectId, issueId, {labels: _.join(labels, ',')}); + } catch (err) { + throw errors.handleGitLabError( + err, + 'Error occurred during adding label in issue.', + this.#user.topcoderUsername, + this.#getIssueUrl(project.full_name, issueId) + ); + } + logger.debug(`Gitlab issue is updated with new labels for ${issueId}`); + } -/** - * Get gitlab repository - * @param {Object} user The user - * @param {Object} repoURL The repository URL - */ -async function getRepository(user, repoURL) { - const refreshedUser = await _refreshGitlabUserAccessToken(user); - const gitlab = await _authenticate(refreshedUser.accessToken); - const _repoURL = repoURL.replace(`${config.GITLAB_API_BASE_URL}/`, ''); - return await gitlab.Projects.show(_repoURL); -} + /** + * Get gitlab repository + * @param {String} repoURL The repository URL + */ + async getRepository(repoURL) { + Joi.attempt({repoURL}, {repoURL: Joi.string().required()}); + const _repoURL = repoURL.replace(`${config.GITLAB_API_BASE_URL}/`, ''); + return await this.#gitlab.Projects.show(_repoURL); + } -/** - * Add a user to a gitlab repository - * @param {Object} copilot The copilot - * @param {import('@gitbeaker/rest').ProjectSchema} repository The repository - * @param {Object} user The user - * @param {import('@gitbeaker/rest').AccessLevel} accessLevel The user role - */ -async function addUserToRepository(copilot, repository, user, accessLevel) { - const refreshedCopilot = await _refreshGitlabUserAccessToken(copilot); - const gitlab = await _authenticate(refreshedCopilot.accessToken); - const member = await new Promise((resolve, reject) => { - gitlab.ProjectMembers.show(repository.id, user.userProviderId) - .then((result) => resolve(result)) - .catch((err) => { + /** + * Add a user to a gitlab repository + * @param {import('@gitbeaker/rest').ProjectSchema} repository The repository + * @param {User} user The user + * @param {import('@gitbeaker/rest').AccessLevel} accessLevel The user role + */ + async addUserToRepository(repository, user, accessLevel) { + Joi.attempt({repository, user, accessLevel}, { + repository: Joi.object().required(), + user: Joi.object().required(), + accessLevel: Joi.number().required() + }); + const member = await new Promise(async (resolve, reject) => { + try { + const res = await this.#gitlab.ProjectMembers.show(repository.id, user.userProviderId); + resolve(res); + } catch (err) { // eslint-disable-next-line no-magic-numbers if (_.get(err, 'cause.response.status') === 404) { - return resolve(null); + resolve(null); } - return reject(err); - }); - }); - if (!member) { - await gitlab.ProjectMembers.add(repository.id, user.userProviderId, accessLevel); - return; + reject(err); + } + }); + if (!member) { + await this.#gitlab.ProjectMembers.add(repository.id, user.userProviderId, accessLevel); + return; + } + if (member.access_level !== accessLevel) { + await this.#gitlab.ProjectMembers.edit(repository.id, user.userProviderId, accessLevel); + } } - if (member.access_level !== accessLevel) { - await gitlab.ProjectMembers.edit(repository.id, user.userProviderId, accessLevel); + + /** + * Fork a gitlab repository + * @param {ProjectSchema} repository The repository + */ + async forkRepository(repository) { + Joi.attempt({repository}, {repository: Joi.object().required()}); + await this.#gitlab.Projects.fork(repository.id); } -} -/** - * Fork a gitlab repository - * @param {Object} user The user - * @param {ProjectSchema} repository The repository - */ -async function forkRepository(user, repository) { - const refreshedUser = await _refreshGitlabUserAccessToken(user); - const gitlab = await _authenticate(refreshedUser.accessToken); - await gitlab.Projects.fork(repository.id); -} + /** + * Get the diff patch for a gitlab merge request + * @param {import('@gitbeaker/rest').MergeRequestSchemaWithBasicLabels} mergeRequest The merge request + * @returns {Promise} The diff patch + */ + async getMergeRequestDiffPatches(mergeRequest) { + Joi.attempt({mergeRequest}, { + mergeRequest: Joi.object().keys({ + web_url: Joi.string().required() + }).unknown(true).required() + }); + const diff = await this.#gitlab.MergeRequests.allDiffs(mergeRequest.target_project_id, mergeRequest.iid); + const patchFile = diff.reduce((acc, file) => { + // Header + acc += `diff --git a/${file.old_path} b/${file.new_path}\n`; + // Index + if (file.new_file) { + acc += `new file mode ${file.b_mode}\n`; + } + if (file.deleted_file) { + acc += `deleted file mode ${file.a_mode}\n`; + } + if (file.diff && file.diff !== '') { + acc += `--- a/${file.new_file ? '/dev/null' : file.old_path}\n`; + acc += `+++ b/${file.deleted_file ? '/dev/null' : file.new_path}\n`; + acc += file.diff; + } else if (file.renamed_file) { + acc += 'similarity index 100%\n'; + acc += `rename from ${file.old_path}\n`; + acc += `rename to ${file.new_path}\n`; + } + return acc; + }, ''); + console.log(patchFile); + return patchFile; + } -/** - * Refresh the copilot access token if token is needed - * @param {Object} copilot the copilot - * @returns {Promise} the promise result of copilot with refreshed token - */ -async function _refreshGitlabUserAccessToken(copilot) { - if (copilot.accessTokenExpiration && new Date().getTime() > copilot.accessTokenExpiration.getTime() - - (config.GITLAB_REFRESH_TOKEN_BEFORE_EXPIRATION * MS_PER_SECOND)) { - const refreshTokenResult = await request - .post(`${config.GITLAB_API_BASE_URL}/oauth/token`) - .query({ - client_id: config.GITLAB_CLIENT_ID, - client_secret: config.GITLAB_CLIENT_SECRET, - refresh_token: copilot.refreshToken, - grant_type: 'refresh_token', - redirect_uri: config.GITLAB_OWNER_USER_CALLBACK_URL - }) - .end(); - // save user token data - const expiresIn = refreshTokenResult.body.expires_in || config.GITLAB_ACCESS_TOKEN_DEFAULT_EXPIRATION; - const updates = { - accessToken: refreshTokenResult.body.access_token, - accessTokenExpiration: new Date(new Date().getTime() + expiresIn * MS_PER_SECOND), - refreshToken: refreshTokenResult.body.refresh_token - }; - copilot = _.assign(copilot, updates); - return await dbHelper.update(models.User, copilot.id, updates); + /** + * Get a list of all merge requests for a gitlab repository + * @param {ProjectSchema} repository The repository + * @param {Number} userId + */ + async getOpenMergeRequestsByUser(repository, userId) { + Joi.attempt({repository, userId}, { + repository: Joi.object().required(), + userId: Joi.number().required() + }); + return this.#gitlab.MergeRequests.all({ + projectId: repository.id, + state: 'opened', + authorId: userId + }); } - return copilot; } -module.exports = { - createComment, - updateIssue, - assignUser, - removeAssign, - getUsernameById, - getUserIdByLogin, - markIssueAsPaid, - changeState, - addLabels, - getRepository, - addUserToRepository, - forkRepository -}; - -logger.buildService(module.exports); +module.exports = GitlabService; + +logger.buildService(module.exports, true); diff --git a/services/PrivateForkService.js b/services/PrivateForkService.js index 5402a22..4d24411 100644 --- a/services/PrivateForkService.js +++ b/services/PrivateForkService.js @@ -1,25 +1,17 @@ -/* eslint-disable no-magic-numbers */ -/* - * Copyright (c) 2018 TopCoder, Inc. All rights reserved. - */ 'use strict'; +/* eslint-disable no-magic-numbers */ -/** - * This service will provide project operations. - * - * @author TCSCODER - * @version 1.0 - */ const Joi = require('joi'); +const uuid = require('uuid').v4; const models = require('../models'); const dbHelper = require('../utils/db-helper'); const logger = require('../utils/logger'); +const errors = require('../utils/errors'); const GitlabService = require('../services/GitlabService'); -const {GITLAB_ACCESS_LEVELS} = require('../constants'); +const {GITLAB_ACCESS_LEVELS, USER_ROLES, USER_TYPES} = require('../constants'); const ProjectChallengeMapping = models.ProjectChallengeMapping; const Project = models.Project; -const Repository = models.Repository; const User = models.User; const GitlabUserMapping = models.GitlabUserMapping; @@ -32,7 +24,11 @@ const GitlabUserMapping = models.GitlabUserMapping; */ async function process(payload) { const {challengeId, memberId, memberHandle} = payload; - const logPrefix = `[PrivateForkService#handleUserRegistration (challengeId: ${challengeId}, memberId: ${memberId}, memberHandle: ${memberHandle})]: `; + const correlationId = uuid(); + const logPrefix = `[Correlation ID: ${correlationId}][PullRequestService#process]`; + logger.debug(`${logPrefix}: Challenge ID: ${challengeId}`); + logger.debug(`${logPrefix}: Member ID: ${memberId}`); + logger.debug(`${logPrefix}: Member Handle: ${memberHandle}`); // Check if there are projects mapped to the challenge const filterValues = {}; const filter = { @@ -46,66 +42,72 @@ async function process(payload) { }; const projectChallengeMapping = await dbHelper.scan(ProjectChallengeMapping, filter, filterValues); if (projectChallengeMapping.length === 0) { - logger.info(`${logPrefix}ProjectChallengeMapping not found for challengeId: ${challengeId}`); + logger.info(`${logPrefix} ProjectChallengeMapping not found for challengeId: ${challengeId}`); return; } - logger.debug(`${logPrefix}ProjectChallengeMapping: ${JSON.stringify(projectChallengeMapping)}`); + logger.debug(`${logPrefix} ProjectChallengeMapping: ${JSON.stringify(projectChallengeMapping)}`); // Get Project const projectId = projectChallengeMapping[0].projectId; const project = await dbHelper.getById(Project, projectId); if (!project) { - logger.info(`${logPrefix}Project not found for projectId: ${projectId}`); + logger.info(`${logPrefix} Project not found for projectId: ${projectId}`); return; } - logger.debug(`${logPrefix}Project: ${JSON.stringify(project)}`); + logger.debug(`${logPrefix} Project: ${JSON.stringify(project)}`); // Get Repositories - const repositories = await dbHelper.queryAllRepositoriesByProjectId(Repository, project.id); + const repositories = await dbHelper.queryAllRepositoriesByProjectId(project.id); if (!repositories || repositories.length === 0) { - logger.info(`${logPrefix}Repository not found for projectId: ${project.id}`); + logger.info(`${logPrefix} Repository not found for projectId: ${project.id}`); return; } - logger.debug(`${logPrefix}Repository: ${JSON.stringify(repositories)}`); + logger.debug(`${logPrefix} Repository: ${JSON.stringify(repositories)}`); // Get Co-pilot GitlabUserMapping const copilotGitlabUserMapping = await dbHelper.queryOneUserMappingByTCUsername(GitlabUserMapping, project.copilot); if (!copilotGitlabUserMapping) { - logger.info(`${logPrefix}GitlabUserMapping not found for copilot: ${project.copilot}`); + logger.info(`${logPrefix} GitlabUserMapping not found for copilot: ${project.copilot}`); return; } - logger.debug(`${logPrefix}GitlabUserMapping[Copilot]: ${JSON.stringify(copilotGitlabUserMapping)}`); + logger.debug(`${logPrefix} GitlabUserMapping[Copilot]: ${JSON.stringify(copilotGitlabUserMapping)}`); // Get Gitlab User - const copilotGitlabUser = await dbHelper.queryOneUserByType(User, copilotGitlabUserMapping.gitlabUsername, 'gitlab'); + const copilotGitlabUser = await dbHelper.queryOneUserByTypeAndRole(User, copilotGitlabUserMapping.gitlabUsername, USER_TYPES.GITLAB, USER_ROLES.OWNER); if (!copilotGitlabUser) { - logger.info(`${logPrefix}GitlabUser not found for copilot: ${project.copilot}`); + logger.info(`${logPrefix} User[Type=${USER_TYPES.GITLAB}, Role=${USER_ROLES.OWNER}] not found for copilot: ${project.copilot}`); return; } - logger.debug(`${logPrefix}GitlabUser[Copilot]: ${JSON.stringify(copilotGitlabUser)}`); + logger.debug(`${logPrefix} GitlabUser[Copilot]: ${JSON.stringify(copilotGitlabUser)}`); + // Initialize Copilot Gitlab Service + const copilotGitlabService = await GitlabService.create(copilotGitlabUser); // Get Member GitlabUserMapping const memberGitlabUserMapping = await dbHelper.queryOneUserMappingByTCUsername(GitlabUserMapping, memberHandle); if (!memberGitlabUserMapping) { - logger.info(`${logPrefix}GitlabUserMapping not found for memberHandle: ${memberHandle}`); + logger.info(`${logPrefix} GitlabUserMapping not found for memberHandle: ${memberHandle}`); return; } - logger.debug(`${logPrefix}GitlabUserMapping[Member]: ${JSON.stringify(memberGitlabUserMapping)}`); + logger.debug(`${logPrefix} GitlabUserMapping[Member]: ${JSON.stringify(memberGitlabUserMapping)}`); // Get Gitlab User - const memberGitlabUser = await dbHelper.queryOneUserByType(User, memberGitlabUserMapping.gitlabUsername, 'gitlab'); + const memberGitlabUser = await dbHelper.queryOneUserByTypeAndRole(User, memberGitlabUserMapping.gitlabUsername, USER_TYPES.GITLAB, USER_ROLES.GUEST); if (!memberGitlabUser) { - logger.info(`${logPrefix}GitlabUser not found for memberHandle: ${memberHandle}`); + logger.info(`${logPrefix} GitlabUser not found for memberHandle: ${memberHandle}`); return; } - logger.debug(`${logPrefix}GitlabUser[Member]: ${JSON.stringify(memberGitlabUser)}`); + logger.debug(`${logPrefix} GitlabUser[Member]: ${JSON.stringify(memberGitlabUser)}`); + // Initialize Member Gitlab Service + const memberGitlabService = await GitlabService.create(memberGitlabUser); await Promise.all(repositories.map(async (repo) => { try { - const repository = await GitlabService.getRepository(copilotGitlabUser, repo.url); + const repository = await memberGitlabService.getRepository(repo.url); if (!repository) { - logger.info(`${logPrefix}Repository not found for repo: ${repo}`); + logger.info(`${logPrefix} Repository not found for repo: ${repo}`); return; } // Add user as a guest to the repo - await GitlabService.addUserToRepository(copilotGitlabUser, repository, memberGitlabUser, GITLAB_ACCESS_LEVELS.DEVELOPER); + await copilotGitlabService.addUserToRepository(repository, memberGitlabUser, GITLAB_ACCESS_LEVELS.DEVELOPER); + logger.debug(`${logPrefix} User (${memberGitlabUser.username}) added to repository (${repo.url})`); // Fork the repository - await GitlabService.forkRepository(memberGitlabUser, repository); + await memberGitlabService.forkRepository(repository); + logger.debug(`${logPrefix} Repository (${repo.url}) forked for user: ${memberGitlabUser.username}`); } catch (err) { - logger.error(`${logPrefix}Error: ${err.message}`, err); + throw errors.handleGitLabError(err, 'Error occurred while forking repository to user\'s namespace in GitLab'); } })); } diff --git a/services/PullRequestService.js b/services/PullRequestService.js new file mode 100644 index 0000000..5cc37c9 --- /dev/null +++ b/services/PullRequestService.js @@ -0,0 +1,148 @@ +'use strict'; + +const _ = require('lodash'); +const Joi = require('joi'); +const uuid = require('uuid').v4; +const models = require('../models'); +const dbHelper = require('../utils/db-helper'); +const logger = require('../utils/logger'); +const GitlabService = require('../services/GitlabService'); + +const GitlabUserMapping = models.GitlabUserMapping; + +/** + * Handles a pull request creation event. + * @param {Object} payload The event payload. + * @param {String} payload.provider The provider (gitlab or github) + * @param {Object} payload.data The event payload. + * @param {Object} payload.data.pull_request The pull request. + * @param {Number} payload.data.pull_request.number The pull request number. + * @param {Number} payload.data.pull_request.id The pull request id. + * @param {Object} payload.data.pull_request.merged The pull request merged flag. + * @param {Object} payload.data.pull_request.body The pull request body. + * @param {Object} payload.data.pull_request.title The pull request title. + * @param {Object} payload.data.pull_request.user The pull request user. + * @param {Number} payload.data.pull_request.user.id The pull request user id. + * @param {Object} payload.data.repository The repository. + * @param {Number} payload.data.repository.id The repository id. + * @param {String} payload.data.repository.name The repository name. + * @param {String} payload.data.repository.full_name The repository full name. + */ +async function process(payload) { + const {provider, data: {pull_request: pullRequest, repository}} = payload; + const {number: prNumber, id, user} = pullRequest; + const {id: userId} = user; + const {id: repoId, name: repoName, full_name: repoFullName} = repository; + const correlationId = uuid(); + const logPrefix = `[${correlationId}][PullRequestService#process]`; + logger.debug(`${logPrefix}: Provider: ${provider}`); + logger.debug(`${logPrefix}: PR Number: ${prNumber}`); + logger.debug(`${logPrefix}: PR Id: ${id}`); + logger.debug(`${logPrefix}: User Id: ${userId}`); + logger.debug(`${logPrefix}: Repo Id: ${repoId}`); + logger.debug(`${logPrefix}: Repo Name: ${repoName}`); + logger.debug(`${logPrefix}: Repo Full Name: ${repoFullName}`); + // 1. Find the TCX user using the GitLab user id (if not found, return) + const submitter = await dbHelper.queryOneUserMappingByGitlabUserId(GitlabUserMapping, userId); + if (!submitter) { + logger.info(`${logPrefix} GitlabUserMapping not found for userId: ${userId}`); + return; + } + logger.debug(`${logPrefix} GitlabUserMapping[Submitter]: ${JSON.stringify(submitter)}`); + // 2. Get the full GitLab project link + const gitlabProjectLink = await GitlabService.getRepoUrl(repoFullName); + logger.debug(`${logPrefix} GitLab project link: ${gitlabProjectLink}`); + // 3. Find the TCX project using the GitLab project link (if not found, return) + const project = await dbHelper.queryOneProjectByRepositoryLink(gitlabProjectLink); + if (!project) { + logger.info(`${logPrefix} Project not found for gitlabProjectLink: ${gitlabProjectLink}`); + return; + } + logger.debug(`${logPrefix} Project: ${JSON.stringify(project)}`); + // 4. Find all repositories corresponding to the TCX project + const repositories = await dbHelper.queryAllRepositoriesByProjectId(project.id); + if (!repositories || repositories.length === 0) { + logger.info(`${logPrefix} Repositories not found for projectId: ${project.id}`); + return; + } + logger.debug(`${logPrefix} Repositories: ${JSON.stringify(repositories)}`); + // 5. Get co-pilot's GitlabUserMapping + const copilot = await dbHelper.queryOneUserMappingByTCUsername(GitlabUserMapping, project.copilot); + if (!copilot) { + logger.info(`${logPrefix} GitlabUserMapping not found for copilot: ${project.copilot}`); + return; + } + logger.debug(`${logPrefix} GitlabUserMapping[Copilot]: ${JSON.stringify(copilot)}`); + // 6. Get co-pilot's Gitlab user + const copilotGitlabUser = await dbHelper.queryOneUserByType(models.User, copilot.gitlabUsername, 'gitlab'); + if (!copilotGitlabUser) { + logger.info(`${logPrefix} GitlabUser not found for copilot: ${project.copilot}`); + return; + } + logger.debug(`${logPrefix} GitlabUser[Copilot]: ${JSON.stringify(copilotGitlabUser)}`); + // 7. For each project, get the repositories + const gitRepositories = await Promise.all(repositories.map((repo) => GitlabService.getRepository(copilotGitlabUser, repo.url))); + if (!gitRepositories || gitRepositories.length === 0) { + logger.info(`${logPrefix} Git repositories not found for repositories: ${JSON.stringify(repositories)}`); + return; + } + logger.debug(`${logPrefix} Git repositories: ${JSON.stringify(gitRepositories)}`); + // 8. For each repository, get the merge requests + const mergeRequests = await Promise.all( + gitRepositories.map((repo) => GitlabService.getOpenMergeRequestsByUser(copilotGitlabUser, repo, submitter.gitlabUserId)) + ); + if (!mergeRequests || mergeRequests.length === 0) { + logger.info(`${logPrefix} Merge requests not found for repositories: ${JSON.stringify(gitRepositories)}`); + return; + } + logger.debug(`${logPrefix} Merge requests: ${JSON.stringify(mergeRequests)}`); + // 9. Ensure that there exists a merge request by the same member as the pull request for each project (if not, return) + // eslint-disable-next-line no-restricted-syntax + for (let i = 0; i < mergeRequests.length; i += 1) { + const mr = mergeRequests[i]; + if (!mr || mr.length === 0) { + logger.info(`${logPrefix} Merge request not found for repository: ${gitRepositories[i].web_url}`); + return; + } + } + // 10. Get the latest merge request for each project + const latestMergeRequests = mergeRequests.map((mrs) => _.maxBy(mrs, (mr) => new Date(mr.created_at).getTime())); + logger.debug(`${logPrefix} Latest merge requests: ${JSON.stringify(latestMergeRequests)}`); + // 11. Create patch files for each merge request + const patches = await Promise.all( + latestMergeRequests.map((mr) => GitlabService.getMergeRequestDiffPatches(copilotGitlabUser, mr)) + ); + if (!patches || patches.length !== latestMergeRequests.length) { + logger.info(`${logPrefix} Patches not found for merge requests.`); + return; + } + logger.debug(`${logPrefix} Patches: ${JSON.stringify(patches)}`); + // 11. Get the topcoder M2M token + // 10. Create a zip file containing all patch files + // 11. Use the submission API to submit the zip file +} + +process.schema = Joi.object().keys({ + event: Joi.string().valid('pull_request.created').required(), + provider: Joi.string().valid('gitlab').required(), + data: Joi.object().keys({ + pullRequest: Joi.object().keys({ + number: Joi.number().required(), + id: Joi.number().required(), + user: Joi.object().keys({ + id: Joi.number().required() + }).required(), + repository: Joi.object().keys({ + id: Joi.number().required(), + name: Joi.string().required(), + full_name: Joi.string().required() + }).required() + }).required() + }).required() +}); + +module.exports = { + process +}; + +logger.buildService(module.exports); diff --git a/utils/db-helper.js b/utils/db-helper.js index 37fb949..18e7f35 100644 --- a/utils/db-helper.js +++ b/utils/db-helper.js @@ -122,6 +122,67 @@ async function queryOneUserByType(model, username, type) { }); } +/** + * Get single data by query parameters + * @param {Object} model The dynamoose model to query + * @param {String} username The user username + * @param {String} type The type of user + * @param {String} role The role of user + * @returns {Promise} + */ +async function queryOneUserByTypeAndRole(model, username, type, role) { + return await new Promise((resolve, reject) => { + model.query('username').eq(username) + .where('type') + .eq(type) + .all() + .exec((err, result) => { + if (err || !result) { + logger.debug(`queryOneUserByType. Error. ${err}`); + return reject(err); + } + const filteredResult = result.filter((item) => item.role === role); + return resolve(filteredResult.count === 0 ? null : filteredResult[0]); + }); + }); +} + +/** + * Query project by repository url + * @param {String} repoUrl the repo url + * @returns {Promise} + */ +async function queryOneProjectByRepositoryLink(repoUrl) { + const projectId = await new Promise((resolve, reject) => { + models.Repository.query('url') + .eq(repoUrl) + .all() + .exec((err, result) => { + if (err) { + return reject(err); + } + return resolve(result); + }); + }); + if (!projectId || projectId.length === 0) { + return null; + } + return await new Promise((resolve, reject) => { + models.Project.queryOne('id') + .eq(projectId[0].projectId) + .all() + .exec((err, result) => { + if (err) { + return reject(err); + } + if (!result || result.length === 0) { + return resolve(null); + } + return resolve(result); + }); + }); +} + /** * Get single data by query parameters * @param {Object} model The dynamoose model to query @@ -224,13 +285,12 @@ async function queryOneUserMappingByGitlabUserId(model, userId) { /** * Get all repositories by project id - * @param {import('dynamoose').ModelConstructor} model The dynamoose model to query * @param {String} projectId The project id * @returns {Promise>} */ -async function queryAllRepositoriesByProjectId(model, projectId) { +async function queryAllRepositoriesByProjectId(projectId) { return await new Promise((resolve, reject) => { - model.scan({projectId: {eq: projectId}}) + models.Repository.scan({projectId: {eq: projectId}}) .all() .exec((err, result) => { if (err || !result) { @@ -383,6 +443,53 @@ async function queryChallengeUUIDsByRepoUrl(repoUrl) { }); } +/** + * Acquire lock on user to prevent concurrent updates + * @param {String} userId ID of the user + * @param {String} lockId ID of the lock + * @param {Number} ttl Time to live (in milliseconds) + * @returns {Promise} The lock object + */ +async function acquireLockOnUser(userId, lockId, ttl) { + const lockExpiration = Date.now() + ttl; + return await new Promise(async (resolve) => { + try { + const res = await models.User.update( + {id: userId}, + {lockId, lockExpiration}, + { + condition: 'attribute_not_exists(lockId) OR (lockExpiration < :lockExpiration)', + conditionValues: {lockExpiration: new Date()}, + returnValues: 'ALL_NEW' + }, + ); + return resolve(res); + } catch (err) { + if (err.code === 'ConditionalCheckFailedException') { + return resolve(null); + } + throw err; + } + }); +} +/** + * Release lock on user + * @param {String} id ID of the user + * @param {String} lockId ID of the lock + * @returns {Promise} The lock object + */ +async function releaseLockOnUser(id, lockId) { + const res = await models.User.update( + {id}, + {lockId: null, lockExpiration: null}, + { + condition: 'lockId = :lockId', + conditionValues: {lockId} + }, + ); + return res; +} + module.exports = { getById, scan, @@ -397,9 +504,13 @@ module.exports = { queryOneUserMappingByGitlabUserId, queryOneUserMappingByGithubUsername, queryOneUserMappingByGitlabUsername, + queryOneUserByTypeAndRole, + queryOneProjectByRepositoryLink, queryOneUserMappingByTCUsername, queryChallengeUUIDsByRepoUrl, queryAllRepositoriesByProjectId, removeCopilotPayment, - removeIssue + removeIssue, + acquireLockOnUser, + releaseLockOnUser }; diff --git a/utils/errors.js b/utils/errors.js index 030c9fa..fe04aa0 100644 --- a/utils/errors.js +++ b/utils/errors.js @@ -64,10 +64,10 @@ errors.handleGitLabError = function handleGitLabError(err, message, copilotHandl if (err.statusCode === 401 && copilotHandle && repoPath) { // eslint-disable-line no-magic-numbers notification.sendTokenExpiredAlert(copilotHandle, repoPath, 'Gitlab'); } - let resMsg = `${message}. ${err.message}.`; - const detail = _.get(err, 'response.body.message'); + let resMsg = `${message}: ${err.message}.`; + const detail = _.get(err, 'response.body') || _.get(err, 'cause.response.body'); if (detail) { - resMsg += ` Detail: ${detail}`; + resMsg += ` Response Body: ${JSON.stringify(detail)}`; } const apiError = new ProcessorError( err.status || _.get(err, 'response.status', constants.SERVICE_ERROR_STATUS), diff --git a/utils/kafka-consumer.js b/utils/kafka-consumer.js index 0023dc4..fe061bc 100644 --- a/utils/kafka-consumer.js +++ b/utils/kafka-consumer.js @@ -15,6 +15,7 @@ const CopilotPaymentService = require('../services/CopilotPaymentService'); const ChallengeService = require('../services/ChallengeService'); const PrivateForkService = require('../services/PrivateForkService'); const NotificationService = require('../services/NotificationService'); +const PullRequestService = require('../services/PullRequestService'); const logger = require('./logger'); const kafka = require('./kafka'); @@ -45,6 +46,7 @@ function tcxMessageHandler(messageSet, topic) { // The event should be a JSON object event = parsePayload(event); try { + console.log(event); event.message.value.payload.value = JSON.parse(event.message.value.payload.value); } catch (e) { logger.error('Invalid message payload', e); @@ -76,6 +78,11 @@ function tcxMessageHandler(messageSet, topic) { .process(payload) .catch(logger.error); } + if (_.includes(['pull_request.created'], payload.event)) { + PullRequestService + .process(payload) + .catch(logger.error); + } }); } diff --git a/utils/logger.js b/utils/logger.js index 4477578..473b392 100644 --- a/utils/logger.js +++ b/utils/logger.js @@ -64,12 +64,12 @@ function sanitizeObject(obj) { * Decorate all functions of a service and log debug information if DEBUG is enabled * @param {Object} service the service */ -logger.decorateWithLogging = function decorateWithLogging(service) { +logger.decorateWithLogging = function decorateWithLogging(service, isClass = false) { if (config.LOG_LEVEL !== 'debug') { return; } - _.forEach(service, (method, name) => { - service[name] = async function serviceMethodWithLogging() { + const forEachIteratee = (method, name, obj) => { + obj[name] = async function serviceMethodWithLogging() { try { const result = await method.apply(this, arguments); // eslint-disable-line return result; @@ -78,15 +78,21 @@ logger.decorateWithLogging = function decorateWithLogging(service) { throw e; } }; - }); + }; + if (isClass) { + _.forEach(service.prototype, forEachIteratee); + } else { + _.forEach(service, forEachIteratee); + } }; /** * Apply logger and validation decorators * @param {Object} service the service to wrap + * @param {Boolean} isClass whether the service is an ES6 class */ -logger.buildService = function buildService(service) { - logger.decorateWithLogging(service); +logger.buildService = function buildService(service, isClass = false) { + logger.decorateWithLogging(service, isClass); }; /** diff --git a/utils/topcoder-api-helper.js b/utils/topcoder-api-helper.js index ade67d0..de63381 100644 --- a/utils/topcoder-api-helper.js +++ b/utils/topcoder-api-helper.js @@ -465,6 +465,9 @@ async function getProjectByDirectId(id, directId) { }); } +async function createSubmission(challengeId, submissionFileStream, submissionFileName, submissionType) { + // TODO: Implement submission creation +} module.exports = { createProject,