diff --git a/lib/registerPlugins.js b/lib/registerPlugins.js index 89630e9f7..4eaf950c8 100644 --- a/lib/registerPlugins.js +++ b/lib/registerPlugins.js @@ -60,7 +60,8 @@ async function registerResourcePlugins(server, config) { 'isAdmin', 'shutdown', 'release', - 'validator' + 'validator', + 'processHooks' ]; if (hoek.reach(config, 'coverage.coveragePlugin')) { diff --git a/plugins/events/create.js b/plugins/events/create.js index 535d42e8e..3b84566c3 100644 --- a/plugins/events/create.js +++ b/plugins/events/create.js @@ -149,7 +149,7 @@ module.exports = () => ({ const [files, prInfo] = await Promise.all([ scm.getChangedFiles({ - payload: null, + webhookConfig: null, type: 'pr', ...scmConfig }), diff --git a/plugins/processHooks/README.md b/plugins/processHooks/README.md new file mode 100644 index 000000000..81f6056c9 --- /dev/null +++ b/plugins/processHooks/README.md @@ -0,0 +1,33 @@ +# Process Hooks Plugin +> Hapi processHooks plugin for the Screwdriver API + +## Usage + +### Register plugin + +```javascript +const Hapi = require('@hapi/hapi'); +const server = new Hapi.Server(); +const processHooksPlugin = require('./'); + +server.connection({ port: 3000 }); + +server.register({ + register: processHooksPlugin, + options: {} +}, () => { + server.start((err) => { + if (err) { + throw err; + } + console.log('Server running at:', server.info.uri); + }); +}); +``` + +### Routes + +#### Start pipeline events from scm webhook config + +`POST /processHooks` + diff --git a/plugins/processHooks/index.js b/plugins/processHooks/index.js new file mode 100644 index 000000000..65b7ce64b --- /dev/null +++ b/plugins/processHooks/index.js @@ -0,0 +1,47 @@ +'use strict'; + +const logger = require('screwdriver-logger'); +const { startHookEvent } = require('../webhooks/helper'); + +/** + * Process Hooks API Plugin + * - Start pipeline events with scm webhook config via queue-service + * @method register + * @param {Hapi} server Hapi Server + * @param {Object} options Configuration + * @param {Function} next Function to call when done + */ +const processHooksPlugin = { + name: 'processHooks', + async register(server, options) { + server.route({ + method: 'POST', + path: '/processHooks', + options: { + description: 'Handle process hook events', + notes: 'Acts on pull request, pushes, comments, etc.', + tags: ['api', 'processHook'], + auth: { + strategies: ['token'], + scope: ['webhook_worker'] + }, + plugins: { + 'hapi-rate-limit': { + enabled: false + } + }, + handler: async (request, h) => { + try { + return await startHookEvent(request, h, request.payload); + } catch (err) { + logger.error(`Error starting hook events for ${request.payload.hookId}:${err}`); + + throw err; + } + } + } + }); + } +}; + +module.exports = processHooksPlugin; diff --git a/plugins/webhooks/helper.js b/plugins/webhooks/helper.js new file mode 100644 index 000000000..cf48a13a7 --- /dev/null +++ b/plugins/webhooks/helper.js @@ -0,0 +1,1192 @@ +'use strict'; + +const workflowParser = require('screwdriver-workflow-parser'); +const schema = require('screwdriver-data-schema'); +const logger = require('screwdriver-logger'); +const { getReadOnlyInfo } = require('../helper'); + +const ANNOT_NS = 'screwdriver.cd'; +const ANNOT_CHAIN_PR = `${ANNOT_NS}/chainPR`; +const ANNOT_RESTRICT_PR = `${ANNOT_NS}/restrictPR`; +const EXTRA_TRIGGERS = schema.config.regex.EXTRA_TRIGGER; +const CHECKOUT_URL_SCHEMA = schema.config.regex.CHECKOUT_URL; +const CHECKOUT_URL_SCHEMA_REGEXP = new RegExp(CHECKOUT_URL_SCHEMA); + +/** + * Check if tag or release filtering is enabled or not + * @param {String} action SCM webhook action type + * @param {Array} workflowGraph pipeline workflowGraph + * @returns {Boolean} isFilteringEnabled + */ +function isReleaseOrTagFilteringEnabled(action, workflowGraph) { + let isFilteringEnabled = true; + + workflowGraph.edges.forEach(edge => { + const releaseOrTagRegExp = action === 'release' ? new RegExp('^~(release)$') : new RegExp('^~(tag)$'); + + if (edge.src.match(releaseOrTagRegExp)) { + isFilteringEnabled = false; + } + }); + + return isFilteringEnabled; +} +/** + * Determine "startFrom" with type, action and branches + * @param {String} action SCM webhook action type + * @param {String} type Triggered SCM event type ('pr' or 'repo') + * @param {String} targetBranch The branch against which commit is pushed + * @param {String} pipelineBranch The pipeline branch + * @param {String} releaseName SCM webhook release name + * @param {String} tagName SCM webhook tag name + * @param {Boolean} isReleaseOrTagFiltering If the tag or release filtering is enabled + * @returns {String} startFrom + */ +function determineStartFrom(action, type, targetBranch, pipelineBranch, releaseName, tagName, isReleaseOrTagFiltering) { + let startFrom; + + if (type && type === 'pr') { + startFrom = '~pr'; + } else { + switch (action) { + case 'release': + return releaseName && isReleaseOrTagFiltering ? `~release:${releaseName}` : '~release'; + case 'tag': + if (!tagName) { + logger.error('The ref of SCM Webhook is missing.'); + + return ''; + } + + return isReleaseOrTagFiltering ? `~tag:${tagName}` : '~tag'; + default: + startFrom = '~commit'; + break; + } + } + + return targetBranch !== pipelineBranch ? `${startFrom}:${targetBranch}` : startFrom; +} + +/** + * Update admins array + * @param {UserFactory} userFactory UserFactory object + * @param {String} username Username of user + * @param {String} scmContext Scm which pipeline's repository exists in + * @param {Pipeline} pipeline Pipeline object + * @param {PipelineFactory}pipelineFactory PipelineFactory object + * @return {Promise} Updates the pipeline admins and throws an error if not an admin + */ +async function updateAdmins(userFactory, username, scmContext, pipeline, pipelineFactory) { + const { readOnlyEnabled } = getReadOnlyInfo({ scm: pipelineFactory.scm, scmContext }); + + // Skip update admins if read-only pipeline + if (readOnlyEnabled) { + return Promise.resolve(); + } + + try { + const user = await userFactory.get({ username, scmContext }); + const userPermissions = await user.getPermissions(pipeline.scmUri); + const newAdmins = pipeline.admins; + + // Delete user from admin list if bad permissions + if (!userPermissions.push) { + delete newAdmins[username]; + // This is needed to make admins dirty and update db + pipeline.admins = newAdmins; + + return pipeline.update(); + } + // Add user as admin if permissions good and does not already exist + if (!pipeline.admins[username]) { + newAdmins[username] = true; + // This is needed to make admins dirty and update db + pipeline.admins = newAdmins; + + return pipeline.update(); + } + } catch (err) { + logger.info(err.message); + } + + return Promise.resolve(); +} + +/** + * Update admins for an array of pipelines + * @param {Object} config.userFactory UserFactory + * @param {Array} config.pipelines An array of pipelines + * @param {String} config.username Username + * @param {String} config.scmContext ScmContext + * @param {PipelineFactory} config.pipelineFactory PipelineFactory object + * @return {Promise} + */ +async function batchUpdateAdmins({ userFactory, pipelines, username, scmContext, pipelineFactory }) { + await Promise.all( + pipelines.map(pipeline => updateAdmins(userFactory, username, scmContext, pipeline, pipelineFactory)) + ); +} + +/** + * Check if the PR is being restricted or not + * @method isRestrictedPR + * @param {String} restriction Is the pipeline restricting PR based on origin + * @param {String} prSource Origin of the PR + * @return {Boolean} Should the build be restricted + */ +function isRestrictedPR(restriction, prSource) { + switch (restriction) { + case 'all': + return true; + case 'branch': + case 'fork': + return prSource === restriction; + case 'none': + default: + return false; + } +} + +/** + * Stop a job by stopping all the builds associated with it + * If the build is running, set state to ABORTED + * @method stopJob + * @param {Object} config + * @param {String} config.action Event action ('Closed' or 'Synchronized') + * @param {Job} config.job Job to stop + * @param {String} config.prNum Pull request number + * @return {Promise} + */ +function stopJob({ job, prNum, action }) { + const stopRunningBuild = build => { + if (build.isDone()) { + return Promise.resolve(); + } + + const statusMessage = + action === 'Closed' + ? `Aborted because PR#${prNum} was closed` + : `Aborted because new commit was pushed to PR#${prNum}`; + + build.status = 'ABORTED'; + build.statusMessage = statusMessage; + + return build.update(); + }; + + return ( + job + .getRunningBuilds() + // Stop running builds + .then(builds => Promise.all(builds.map(stopRunningBuild))) + ); +} + +/** + * Check if the pipeline has a triggered job or not + * @method hasTriggeredJob + * @param {Pipeline} pipeline The pipeline to check + * @param {String} startFrom The trigger name + * @returns {Boolean} True if the pipeline contains the triggered job + */ +function hasTriggeredJob(pipeline, startFrom) { + try { + const nextJobs = workflowParser.getNextJobs(pipeline.workflowGraph, { + trigger: startFrom + }); + + return nextJobs.length > 0; + } catch (err) { + logger.error(`Error finding triggered jobs for ${pipeline.id}: ${err}`); + + return false; + } +} + +/** + * Check if changedFiles are under rootDir. If no custom rootDir, return true. + * @param {Object} pipeline + * @param {Array} changedFiles + * @return {Boolean} + */ +function hasChangesUnderRootDir(pipeline, changedFiles) { + const splitUri = pipeline.scmUri.split(':'); + const rootDir = splitUri.length > 3 ? splitUri[3] : ''; + const changes = changedFiles || []; + + // Only check if rootDir is set + if (rootDir) { + return changes.some(file => file.startsWith(rootDir)); + } + + return true; +} + +/** + * Resolve ChainPR flag + * @method resolveChainPR + * @param {Boolean} chainPR Plugin Chain PR flag + * @param {Pipeline} pipeline Pipeline + * @param {Object} pipeline.annotations Pipeline-level annotations + * @return {Boolean} + */ +function resolveChainPR(chainPR, pipeline) { + const defaultChainPR = typeof chainPR === 'undefined' ? false : chainPR; + const annotChainPR = pipeline.annotations[ANNOT_CHAIN_PR]; + + return typeof annotChainPR === 'undefined' ? defaultChainPR : annotChainPR; +} + +/** + * Returns an object with resolvedChainPR and skipMessage + * @param {Object} config.pipeline Pipeline + * @param {String} config.prSource The origin of this PR + * @param {String} config.restrictPR Restrict PR setting + * @param {Boolean} config.chainPR Chain PR flag + * @return {Object} + */ +function getSkipMessageAndChainPR({ pipeline, prSource, restrictPR, chainPR }) { + const defaultRestrictPR = restrictPR || 'none'; + const restriction = pipeline.annotations[ANNOT_RESTRICT_PR] || defaultRestrictPR; + const result = { + resolvedChainPR: resolveChainPR(chainPR, pipeline) + }; + + // Check for restriction upfront + if (isRestrictedPR(restriction, prSource)) { + result.skipMessage = `Skipping build since pipeline is configured to restrict ${restriction} and PR is ${prSource}`; + } + + return result; +} + +/** + * Returns the uri keeping only the host and the repo ID + * @method uriTrimmer + * @param {String} uri The uri to be trimmed + * @return {String} + */ +const uriTrimmer = uri => { + const uriToArray = uri.split(':'); + + while (uriToArray.length > 2) uriToArray.pop(); + + return uriToArray.join(':'); +}; + +/** + * Get all pipelines which has triggered job + * @method triggeredPipelines + * @param {PipelineFactory} pipelineFactory The pipeline factory to get the branch list from + * @param {Object} scmConfig Has the token and scmUri to get branches + * @param {String} branch The branch which is committed + * @param {String} type Triggered GitHub event type ('pr' or 'repo') + * @param {String} action Triggered GitHub event action + * @param {Array} changedFiles Changed files in this commit + * @param {String} releaseName SCM webhook release name + * @param {String} tagName SCM webhook tag name + * @returns {Promise} Promise that resolves into triggered pipelines + */ +async function triggeredPipelines( + pipelineFactory, + scmConfig, + branch, + type, + action, + changedFiles, + releaseName, + tagName +) { + const { scmUri } = scmConfig; + const splitUri = scmUri.split(':'); + const scmBranch = `${splitUri[0]}:${splitUri[1]}:${splitUri[2]}`; + const scmRepoId = `${splitUri[0]}:${splitUri[1]}`; + const listConfig = { search: { field: 'scmUri', keyword: `${scmRepoId}:%` } }; + const externalRepoSearchConfig = { search: { field: 'subscribedScmUrlsWithActions', keyword: `%${scmRepoId}:%` } }; + + const pipelines = await pipelineFactory.list(listConfig); + + const pipelinesWithSubscribedRepos = await pipelineFactory.list(externalRepoSearchConfig); + + let pipelinesOnCommitBranch = []; + let pipelinesOnOtherBranch = []; + + pipelines.forEach(p => { + // This uri expects 'scmUriDomain:repoId:branchName:rootDir'. To Compare, rootDir is ignored. + const splitScmUri = p.scmUri.split(':'); + const pipelineScmBranch = `${splitScmUri[0]}:${splitScmUri[1]}:${splitScmUri[2]}`; + + if (pipelineScmBranch === scmBranch) { + pipelinesOnCommitBranch.push(p); + } else { + pipelinesOnOtherBranch.push(p); + } + }); + + // Build runs regardless of changedFiles when release/tag trigger + pipelinesOnCommitBranch = pipelinesOnCommitBranch.filter( + p => ['release', 'tag'].includes(action) || hasChangesUnderRootDir(p, changedFiles) + ); + + pipelinesOnOtherBranch = pipelinesOnOtherBranch.filter(p => { + let isReleaseOrTagFiltering = ''; + + if (action === 'release' || action === 'tag') { + isReleaseOrTagFiltering = isReleaseOrTagFilteringEnabled(action, p.workflowGraph); + } + + return hasTriggeredJob( + p, + determineStartFrom(action, type, branch, null, releaseName, tagName, isReleaseOrTagFiltering) + ); + }); + + const currentRepoPipelines = pipelinesOnCommitBranch.concat(pipelinesOnOtherBranch); + + return currentRepoPipelines.concat(pipelinesWithSubscribedRepos); +} + +/** + * Create events for each pipeline + * @async createPREvents + * @param {Object} options + * @param {String} options.username User who created the PR + * @param {String} options.scmConfig Has the token and scmUri to get branches + * @param {String} options.sha Specific SHA1 commit to start the build with + * @param {String} options.prRef Reference to pull request + * @param {String} options.prNum Pull request number + * @param {String} options.prTitle Pull request title + * @param {Array} options.changedFiles List of changed files + * @param {String} options.branch The branch against which pr is opened + * @param {String} options.action Event action + * @param {String} options.prSource The origin of this PR + * @param {String} options.restrictPR Restrict PR setting + * @param {Boolean} options.chainPR Chain PR flag + * @param {Hapi.request} request Request from user + * @return {Promise} + */ +async function createPREvents(options, request) { + const { + username, + scmConfig, + prRef, + prNum, + pipelines, + prTitle, + changedFiles, + branch, + action, + prSource, + restrictPR, + chainPR, + ref, + releaseName, + meta + } = options; + const { scm } = request.server.app.pipelineFactory; + const { eventFactory, pipelineFactory, userFactory } = request.server.app; + const scmDisplayName = scm.getDisplayName({ scmContext: scmConfig.scmContext }); + const userDisplayName = `${scmDisplayName}:${username}`; + const events = []; + let { sha } = options; + + scmConfig.prNum = prNum; + + const eventConfigs = await Promise.all( + pipelines.map(async p => { + try { + const b = await p.branch; + // obtain pipeline's latest commit sha for branch specific job + let configPipelineSha = ''; + let subscribedConfigSha = ''; + let eventConfig = {}; + + // Check if the webhook event is from a subscribed repo and + // and fetch the source repo commit sha and save the subscribed sha + if (uriTrimmer(scmConfig.scmUri) !== uriTrimmer(p.scmUri)) { + subscribedConfigSha = sha; + + try { + sha = await pipelineFactory.scm.getCommitSha({ + scmUri: p.scmUri, + scmContext: scmConfig.scmContext, + token: scmConfig.token + }); + } catch (err) { + if (err.status >= 500) { + throw err; + } else { + logger.info(`skip create event for branch: ${b}`); + } + } + + configPipelineSha = sha; + } else { + try { + configPipelineSha = await pipelineFactory.scm.getCommitSha(scmConfig); + } catch (err) { + if (err.status >= 500) { + throw err; + } else { + logger.info(`skip create event for branch: ${b}`); + } + } + } + + const { skipMessage, resolvedChainPR } = getSkipMessageAndChainPR({ + // Workaround for pipelines which has NULL value in `pipeline.annotations` + pipeline: !p.annotations ? { annotations: {}, ...p } : p, + prSource, + restrictPR, + chainPR + }); + + const prInfo = await eventFactory.scm.getPrInfo(scmConfig); + + eventConfig = { + pipelineId: p.id, + type: 'pr', + webhooks: true, + username, + scmContext: scmConfig.scmContext, + sha, + configPipelineSha, + startFrom: `~pr:${branch}`, + changedFiles, + causeMessage: `${action} by ${userDisplayName}`, + chainPR: resolvedChainPR, + prRef, + prNum, + prTitle, + prInfo, + prSource, + baseBranch: branch + }; + + if (b === branch) { + eventConfig.startFrom = '~pr'; + } + + // Check if the webhook event is from a subscribed repo and + // set the jobs entrypoint from ~startFrom + // For subscribed PR event, it should be mimicked as a commit + // in order to function properly + if (uriTrimmer(scmConfig.scmUri) !== uriTrimmer(p.scmUri)) { + eventConfig = { + pipelineId: p.id, + type: 'pipeline', + webhooks: true, + username, + scmContext: scmConfig.scmContext, + startFrom: '~subscribe', + sha, + configPipelineSha, + changedFiles, + baseBranch: branch, + causeMessage: `Merged by ${username}`, + meta, + releaseName, + ref, + subscribedEvent: true, + subscribedConfigSha, + subscribedSourceUrl: prInfo.url + }; + + await updateAdmins(userFactory, username, scmConfig.scmContext, p.id, pipelineFactory); + } + + if (skipMessage) { + eventConfig.skipMessage = skipMessage; + } + + return eventConfig; + } catch (err) { + logger.warn(`pipeline:${p.id} error in starting event`, err); + + return null; + } + }) + ); + + eventConfigs.forEach(eventConfig => { + if (eventConfig && eventConfig.configPipelineSha) { + events.push(eventFactory.create(eventConfig)); + } + }); + + return Promise.all(events); +} + +/** + * Stop all the relevant PR jobs for an array of pipelines + * @async batchStopJobs + * @param {Array} config.pipelines An array of pipeline + * @param {Integer} config.prNum PR number + * @param {String} config.action Event action + * @param {String} config.name Prefix of the PR job name: PR-prNum + */ +async function batchStopJobs({ pipelines, prNum, action, name }) { + const prJobs = await Promise.all( + pipelines.map(p => p.getJobs({ type: 'pr' }).then(jobs => jobs.filter(j => j.name.includes(name)))) + ); + const flatPRJobs = prJobs.reduce((prev, curr) => prev.concat(curr)); + + await Promise.all(flatPRJobs.map(j => stopJob({ job: j, prNum, action }))); +} + +/** + * Create a new job and start the build for an opened pull-request + * @async pullRequestOpened + * @param {Object} options + * @param {String} options.hookId Unique ID for this scm event + * @param {String} options.prSource The origin of this PR + * @param {Pipeline} options.pipeline Pipeline model for the pr + * @param {String} options.restrictPR Restrict PR setting + * @param {Boolean} options.chainPR Chain PR flag + * @param {Hapi.request} request Request from user + * @param {Hapi.h} h Response toolkit + */ +async function pullRequestOpened(options, request, h) { + const { hookId } = options; + + return createPREvents(options, request) + .then(events => { + events.forEach(e => { + request.log(['webhook', hookId, e.id], `Event ${e.id} started`); + }); + + return h.response().code(201); + }) + .catch(err => { + logger.error( + `Failed to pullRequestOpened: [${hookId}, pipeline:${options.pipeline && options.pipeline.id}]: ${err}` + ); + + throw err; + }); +} + +/** + * Stop any running builds and disable the job for closed pull-request + * @async pullRequestClosed + * @param {Object} options + * @param {String} options.hookId Unique ID for this scm event + * @param {Pipeline} options.pipeline Pipeline model for the pr + * @param {String} options.name Name of the PR: PR-prNum + * @param {String} options.prNum Pull request number + * @param {String} options.action Event action + * @param {String} options.fullCheckoutUrl CheckoutUrl with branch name + * @param {Hapi.request} request Request from user + * @param {Hapi.reply} reply Reply to user + */ +async function pullRequestClosed(options, request, h) { + const { pipelines, hookId, name, prNum, action } = options; + const updatePRJobs = job => + stopJob({ job, prNum, action }) + .then(() => request.log(['webhook', hookId, job.id], `${job.name} stopped`)) + .then(() => { + job.archived = true; + + return job.update(); + }) + .then(() => request.log(['webhook', hookId, job.id], `${job.name} disabled and archived`)); + + return Promise.all( + pipelines.map(p => + p.getJobs({ type: 'pr' }).then(jobs => { + const prJobs = jobs.filter(j => j.name.includes(name)); + + return Promise.all(prJobs.map(j => updatePRJobs(j))); + }) + ) + ) + .then(() => h.response().code(200)) + .catch(err => { + logger.error( + `Failed to pullRequestClosed: [${hookId}, pipeline:${options.pipeline && options.pipeline.id}]: ${err}` + ); + + throw err; + }); +} + +/** + * Stop any running builds and start the build for the synchronized pull-request + * @async pullRequestSync + * @param {Object} options + * @param {String} options.hookId Unique ID for this scm event + * @param {String} options.name Name of the new job (PR-1) + * @param {String} options.prSource The origin of this PR + * @param {String} options.restrictPR Restrict PR setting + * @param {Boolean} options.chainPR Chain PR flag + * @param {Pipeline} options.pipeline Pipeline model for the pr + * @param {Array} options.changedFiles List of files that were changed + * @param {String} options.prNum Pull request number + * @param {String} options.action Event action + * @param {Hapi.request} request Request from user + * @param {Hapi.reply} reply Reply to user + */ +async function pullRequestSync(options, request, h) { + const { pipelines, hookId, name, prNum, action } = options; + + await batchStopJobs({ pipelines, name, prNum, action }); + + request.log(['webhook', hookId], `Job(s) for ${name} stopped`); + + return createPREvents(options, request) + .then(events => { + events.forEach(e => { + request.log(['webhook', hookId, e.id], `Event ${e.id} started`); + }); + + return h.response().code(201); + }) + .catch(err => { + logger.error( + `Failed to pullRequestSync: [${hookId}, pipeline:${options.pipeline && options.pipeline.id}]: ${err}` + ); + + throw err; + }); +} + +/** + * Obtains the SCM token for a given user. + * If a user does not have a valid SCM token registered with Screwdriver, + * it will use a generic user's token instead. + * If pipeline is in read-only SCM, use read-only token. + * Some SCM services have different thresholds between IP requests and token requests. This is + * to ensure we have a token to access the SCM service without being restricted by these quotas + * @method obtainScmToken + * @param {Object} pluginOptions + * @param {String} pluginOptions.username Generic scm username + * @param {UserFactory} userFactory UserFactory object + * @param {String} username Name of the user that the SCM token is associated with + * @param {String} scmContext Scm which pipeline's repository exists in + * @param {Object} scm Scm + * @return {Promise} Promise that resolves into a SCM token + */ +async function obtainScmToken({ pluginOptions, userFactory, username, scmContext, scm }) { + const { readOnlyEnabled, headlessAccessToken } = getReadOnlyInfo({ scm, scmContext }); + + // If pipeline is in read-only SCM, use read-only token + if (readOnlyEnabled && headlessAccessToken) { + return headlessAccessToken; + } + + const user = await userFactory.get({ username, scmContext }); + + // Use generic username and token + if (!user) { + const genericUsername = pluginOptions.username; + const buildBotUser = await userFactory.get({ username: genericUsername, scmContext }); + + return buildBotUser.unsealToken(); + } + + return user.unsealToken(); +} + +/** + * Create metadata by the parsed event + * @param {Object} parsed It has information to create metadata + * @returns {Object} Metadata + */ +function createMeta(parsed) { + const { action, ref, releaseId, releaseName, releaseAuthor } = parsed; + + if (action === 'release') { + return { + sd: { + release: { + id: releaseId, + name: releaseName, + author: releaseAuthor + }, + tag: { + name: ref + } + } + }; + } + if (action === 'tag') { + return { + sd: { + tag: { + name: ref + } + } + }; + } + + return {}; +} + +/** + * Act on a Pull Request change (create, sync, close) + * - Opening a PR should sync the pipeline (creating the job) and start the new PR job + * - Syncing a PR should stop the existing PR job and start a new one + * - Closing a PR should stop the PR job and sync the pipeline (disabling the job) + * @method pullRequestEvent + * @param {Object} pluginOptions + * @param {String} pluginOptions.username Generic scm username + * @param {String} pluginOptions.restrictPR Restrict PR setting + * @param {Boolean} pluginOptions.chainPR Chain PR flag + * @param {Hapi.request} request Request from user + * @param {Hapi.reply} reply Reply to user + * @param {String} token The token used to authenticate to the SCM + * @param {Object} parsed + */ +function pullRequestEvent(pluginOptions, request, h, parsed, token) { + const { pipelineFactory, userFactory } = request.server.app; + const { + hookId, + action, + checkoutUrl, + branch, + sha, + prNum, + prTitle, + prRef, + prSource, + username, + scmContext, + changedFiles, + type, + releaseName, + ref + } = parsed; + const fullCheckoutUrl = `${checkoutUrl}#${branch}`; + const scmConfig = { + scmUri: '', + token, + scmContext + }; + const { restrictPR, chainPR } = pluginOptions; + const meta = createMeta(parsed); + + request.log(['webhook', hookId], `PR #${prNum} ${action} for ${fullCheckoutUrl}`); + + return pipelineFactory.scm + .parseUrl({ + checkoutUrl: fullCheckoutUrl, + token, + scmContext + }) + .then(scmUri => { + scmConfig.scmUri = scmUri; + + return triggeredPipelines(pipelineFactory, scmConfig, branch, type, action, changedFiles, releaseName, ref); + }) + .then(async pipelines => { + if (!pipelines || pipelines.length === 0) { + const message = `Skipping since Pipeline triggered by PRs against ${fullCheckoutUrl} does not exist`; + + request.log(['webhook', hookId], message); + + return h.response({ message }).code(204); + } + + const options = { + name: `PR-${prNum}`, + hookId, + sha, + username, + scmConfig, + prRef, + prNum, + prTitle, + prSource, + changedFiles, + action: action.charAt(0).toUpperCase() + action.slice(1), + branch, + fullCheckoutUrl, + restrictPR, + chainPR, + pipelines, + ref, + releaseName, + meta + }; + + await batchUpdateAdmins({ userFactory, pipelines, username, scmContext, pipelineFactory }); + + switch (action) { + case 'opened': + case 'reopened': + return pullRequestOpened(options, request, h); + case 'synchronized': + return pullRequestSync(options, request, h); + case 'closed': + default: + return pullRequestClosed(options, request, h); + } + }) + .catch(err => { + logger.error(`[${hookId}]: ${err}`); + + throw err; + }); +} + +/** + * Create events for each pipeline + * @async createEvents + * @param {EventFactory} eventFactory To create event + * @param {UserFactory} userFactory To get user permission + * @param {PipelineFactory} pipelineFactory To use scm module + * @param {Array} pipelines The pipelines to start events + * @param {Object} parsed It has information to create event + * @param {String} [skipMessage] Message to skip starting builds + * @returns {Promise} Promise that resolves into events + */ +async function createEvents( + eventFactory, + userFactory, + pipelineFactory, + pipelines, + parsed, + skipMessage, + scmConfigFromHook +) { + const { action, branch, sha, username, scmContext, changedFiles, type, releaseName, ref } = parsed; + const events = []; + const meta = createMeta(parsed); + + const pipelineTuples = await Promise.all( + pipelines.map(async p => { + const resolvedBranch = await p.branch; + let isReleaseOrTagFiltering = ''; + + if (action === 'release' || action === 'tag') { + isReleaseOrTagFiltering = isReleaseOrTagFilteringEnabled(action, p.workflowGraph); + } + const startFrom = determineStartFrom( + action, + type, + branch, + resolvedBranch, + releaseName, + ref, + isReleaseOrTagFiltering + ); + const tuple = { branch: resolvedBranch, pipeline: p, startFrom }; + + return tuple; + }) + ); + + const ignoreExtraTriggeredPipelines = pipelineTuples.filter(t => { + // empty event is not created when it is triggered by extra triggers (e.g. ~tag, ~release) + if (EXTRA_TRIGGERS.test(t.startFrom) && !hasTriggeredJob(t.pipeline, t.startFrom)) { + logger.warn(`Event not created: there are no jobs triggered by ${t.startFrom}`); + + return false; + } + + return true; + }); + + const eventConfigs = await Promise.all( + ignoreExtraTriggeredPipelines.map(async pTuple => { + try { + const pipelineBranch = pTuple.branch; + let isReleaseOrTagFiltering = ''; + + if (action === 'release' || action === 'tag') { + isReleaseOrTagFiltering = isReleaseOrTagFilteringEnabled(action, pTuple.pipeline.workflowGraph); + } + const startFrom = determineStartFrom( + action, + type, + branch, + pipelineBranch, + releaseName, + ref, + isReleaseOrTagFiltering + ); + const token = await pTuple.pipeline.token; + const scmConfig = { + scmUri: pTuple.pipeline.scmUri, + token, + scmContext + }; + // obtain pipeline's latest commit sha for branch specific job + let configPipelineSha = ''; + + try { + configPipelineSha = await pipelineFactory.scm.getCommitSha(scmConfig); + } catch (err) { + if (err.status >= 500) { + throw err; + } else { + logger.info(`skip create event for branch: ${pipelineBranch}`); + } + } + const eventConfig = { + pipelineId: pTuple.pipeline.id, + type: 'pipeline', + webhooks: true, + username, + scmContext, + startFrom, + sha, + configPipelineSha, + changedFiles, + baseBranch: branch, + causeMessage: `Merged by ${username}`, + meta, + releaseName, + ref + }; + + // Check is the webhook event is from a subscribed repo and + // set the jobs entry point to ~subscribe + if (uriTrimmer(scmConfigFromHook.scmUri) !== uriTrimmer(pTuple.pipeline.scmUri)) { + eventConfig.subscribedEvent = true; + eventConfig.startFrom = '~subscribe'; + eventConfig.subscribedConfigSha = eventConfig.sha; + + try { + eventConfig.sha = await pipelineFactory.scm.getCommitSha(scmConfig); + } catch (err) { + if (err.status >= 500) { + throw err; + } else { + logger.info(`skip create event for this subscribed trigger`); + } + } + + try { + const commitInfo = await pipelineFactory.scm.decorateCommit({ + scmUri: scmConfigFromHook.scmUri, + scmContext, + sha: eventConfig.subscribedConfigSha, + token + }); + + eventConfig.subscribedSourceUrl = commitInfo.url; + } catch (err) { + if (err.status >= 500) { + throw err; + } else { + logger.info(`skip create event for this subscribed trigger`); + } + } + } + + if (skipMessage) { + eventConfig.skipMessage = skipMessage; + } + + await updateAdmins(userFactory, username, scmContext, pTuple.pipeline, pipelineFactory); + + return eventConfig; + } catch (err) { + logger.warn(`pipeline:${pTuple.pipeline.id} error in starting event`, err); + + return null; + } + }) + ); + + eventConfigs.forEach(eventConfig => { + if (eventConfig && eventConfig.configPipelineSha) { + events.push(eventFactory.create(eventConfig)); + } + }); + + return Promise.all(events); +} + +/** + * Act on a Push event + * - Should start a new main job + * @method pushEvent + * @param {Hapi.request} request Request from user + * @param {Hapi.h} h Response toolkit + * @param {Object} parsed It has information to create event + * @param {String} token The token used to authenticate to the SCM + * @param {String} [skipMessage] Message to skip starting builds + */ +async function pushEvent(request, h, parsed, skipMessage, token) { + const { eventFactory, pipelineFactory, userFactory } = request.server.app; + const { hookId, checkoutUrl, branch, scmContext, type, action, changedFiles, releaseName, ref } = parsed; + const fullCheckoutUrl = `${checkoutUrl}#${branch}`; + const scmConfig = { + scmUri: '', + token: '', + scmContext + }; + + request.log(['webhook', hookId], `Push for ${fullCheckoutUrl}`); + + try { + scmConfig.token = token; + scmConfig.scmUri = await pipelineFactory.scm.parseUrl({ + checkoutUrl: fullCheckoutUrl, + token, + scmContext + }); + + const pipelines = await triggeredPipelines( + pipelineFactory, + scmConfig, + branch, + type, + action, + changedFiles, + releaseName, + ref + ); + let events = []; + + if (!pipelines || pipelines.length === 0) { + request.log(['webhook', hookId], `Skipping since Pipeline ${fullCheckoutUrl} does not exist`); + } else { + events = await createEvents( + eventFactory, + userFactory, + pipelineFactory, + pipelines, + parsed, + skipMessage, + scmConfig + ); + } + + const hasBuildEvents = events.filter(e => e.builds !== null); + + if (hasBuildEvents.length === 0) { + return h.response({ message: 'No jobs to start' }).code(204); + } + + hasBuildEvents.forEach(e => { + request.log(['webhook', hookId, e.id], `Event ${e.id} started`); + }); + + return h.response().code(201); + } catch (err) { + logger.error(`[${hookId}]: ${err}`); + + throw err; + } +} + +/** Execute scm.getCommitRefSha() + * @method getCommitRefSha + * @param {Object} scm + * @param {String} token The token used to authenticate to the SCM + * @param {String} ref The reference which we want + * @param {String} checkoutUrl Scm checkout URL + * @param {String} scmContext Scm which pipeline's repository exists in + * @returns {Promise} Specific SHA1 commit to start the build with + */ +async function getCommitRefSha({ scm, token, ref, refType, checkoutUrl, scmContext }) { + // For example, git@github.com:screwdriver-cd/data-schema.git => screwdriver-cd, data-schema + const owner = CHECKOUT_URL_SCHEMA_REGEXP.exec(checkoutUrl)[2]; + const repo = CHECKOUT_URL_SCHEMA_REGEXP.exec(checkoutUrl)[3]; + + return scm.getCommitRefSha({ + token, + owner, + repo, + ref, + refType, + scmContext + }); +} + +/** + * Start pipeline events with scm webhook config + * @method startHookEvent + * @param {Hapi.request} request Request from user + * @param {Object} h Response toolkit + * @param {Object} webhookConfig Configuration required to start events + * @return {Promise} + */ +async function startHookEvent(request, h, webhookConfig) { + const { userFactory, pipelineFactory } = request.server.app; + const { scm } = pipelineFactory; + const ignoreUser = webhookConfig.pluginOptions.ignoreCommitsBy; + let message = 'Unable to process this kind of event'; + let skipMessage; + let parsedHookId = ''; + + const { type, hookId, username, scmContext, ref, checkoutUrl, action, prNum } = webhookConfig; + + parsedHookId = hookId; + + try { + // skipping checks + if (/\[(skip ci|ci skip)\]/.test(webhookConfig.lastCommitMessage)) { + skipMessage = 'Skipping due to the commit message: [skip ci]'; + } + + // if skip ci then don't return + if (ignoreUser && ignoreUser.length !== 0 && !skipMessage) { + const commitAuthors = + Array.isArray(webhookConfig.commitAuthors) && webhookConfig.commitAuthors.length !== 0 + ? webhookConfig.commitAuthors + : [username]; + const validCommitAuthors = commitAuthors.filter(author => !ignoreUser.includes(author)); + + if (!validCommitAuthors.length) { + message = `Skipping because user ${username} is ignored`; + request.log(['webhook', hookId], message); + + return h.response({ message }).code(204); + } + } + + const token = await obtainScmToken({ pluginOptions: webhookConfig.pluginOptions, userFactory, username, scmContext, scm }); + + if (action !== 'release' && action !== 'tag') { + let scmUri; + + if (type === 'pr') { + scmUri = await scm.parseUrl({ checkoutUrl, token, scmContext }); + } + webhookConfig.changedFiles = await scm.getChangedFiles({ + webhookConfig, + type, + token, + scmContext, + scmUri, + prNum + }); + request.log(['webhook', hookId], `Changed files are ${webhookConfig.changedFiles}`); + } else { + // The payload has no sha when webhook event is tag or release, so we need to get it. + try { + webhookConfig.sha = await getCommitRefSha({ + scm, + token, + ref, + refType: 'tags', + checkoutUrl, + scmContext + }); + } catch (err) { + request.log(['webhook', hookId, 'getCommitRefSha'], err); + + // there is a possibility of scm.getCommitRefSha() is not implemented yet + return h.response({ message }).code(204); + } + } + + if (type === 'pr') { + // disregard skip ci for pull request events + return pullRequestEvent(webhookConfig.pluginOptions, request, h, webhookConfig, token); + } + + return pushEvent(request, h, webhookConfig, skipMessage, token); + } catch (err) { + logger.error(`[${parsedHookId}]: ${err}`); + + throw err; + } +} + +module.exports = { startHookEvent }; diff --git a/plugins/webhooks/index.js b/plugins/webhooks/index.js index dbab6f508..8809171b6 100644 --- a/plugins/webhooks/index.js +++ b/plugins/webhooks/index.js @@ -1,1105 +1,11 @@ 'use strict'; const joi = require('joi'); -const workflowParser = require('screwdriver-workflow-parser'); -const schema = require('screwdriver-data-schema'); const logger = require('screwdriver-logger'); -const { getReadOnlyInfo } = require('../helper'); +const { startHookEvent } = require('./helper'); -const ANNOT_NS = 'screwdriver.cd'; -const ANNOT_CHAIN_PR = `${ANNOT_NS}/chainPR`; -const ANNOT_RESTRICT_PR = `${ANNOT_NS}/restrictPR`; -const EXTRA_TRIGGERS = schema.config.regex.EXTRA_TRIGGER; -const CHECKOUT_URL_SCHEMA = schema.config.regex.CHECKOUT_URL; -const CHECKOUT_URL_SCHEMA_REGEXP = new RegExp(CHECKOUT_URL_SCHEMA); const DEFAULT_MAX_BYTES = 1048576; -/** - * Check if tag or release filtering is enabled or not - * @param {String} action SCM webhook action type - * @param {Array} workflowGraph pipeline workflowGraph - * @returns {Boolean} isFilteringEnabled - */ -function isReleaseOrTagFilteringEnabled(action, workflowGraph) { - let isFilteringEnabled = true; - - workflowGraph.edges.forEach(edge => { - const releaseOrTagRegExp = action === 'release' ? new RegExp('^~(release)$') : new RegExp('^~(tag)$'); - - if (edge.src.match(releaseOrTagRegExp)) { - isFilteringEnabled = false; - } - }); - - return isFilteringEnabled; -} -/** - * Determine "startFrom" with type, action and branches - * @param {String} action SCM webhook action type - * @param {String} type Triggered SCM event type ('pr' or 'repo') - * @param {String} targetBranch The branch against which commit is pushed - * @param {String} pipelineBranch The pipeline branch - * @param {String} releaseName SCM webhook release name - * @param {String} tagName SCM webhook tag name - * @param {Boolean} isReleaseOrTagFiltering If the tag or release filtering is enabled - * @returns {String} startFrom - */ -function determineStartFrom(action, type, targetBranch, pipelineBranch, releaseName, tagName, isReleaseOrTagFiltering) { - let startFrom; - - if (type && type === 'pr') { - startFrom = '~pr'; - } else { - switch (action) { - case 'release': - return releaseName && isReleaseOrTagFiltering ? `~release:${releaseName}` : '~release'; - case 'tag': - if (!tagName) { - logger.error('The ref of SCM Webhook is missing.'); - - return ''; - } - - return isReleaseOrTagFiltering ? `~tag:${tagName}` : '~tag'; - default: - startFrom = '~commit'; - break; - } - } - - return targetBranch !== pipelineBranch ? `${startFrom}:${targetBranch}` : startFrom; -} - -/** - * Update admins array - * @param {UserFactory} userFactory UserFactory object - * @param {String} username Username of user - * @param {String} scmContext Scm which pipeline's repository exists in - * @param {Pipeline} pipeline Pipeline object - * @param {PipelineFactory}pipelineFactory PipelineFactory object - * @return {Promise} Updates the pipeline admins and throws an error if not an admin - */ -async function updateAdmins(userFactory, username, scmContext, pipeline, pipelineFactory) { - const { readOnlyEnabled } = getReadOnlyInfo({ scm: pipelineFactory.scm, scmContext }); - - // Skip update admins if read-only pipeline - if (readOnlyEnabled) { - return Promise.resolve(); - } - - try { - const user = await userFactory.get({ username, scmContext }); - const userPermissions = await user.getPermissions(pipeline.scmUri); - const newAdmins = pipeline.admins; - - // Delete user from admin list if bad permissions - if (!userPermissions.push) { - delete newAdmins[username]; - // This is needed to make admins dirty and update db - pipeline.admins = newAdmins; - - return pipeline.update(); - } - // Add user as admin if permissions good and does not already exist - if (!pipeline.admins[username]) { - newAdmins[username] = true; - // This is needed to make admins dirty and update db - pipeline.admins = newAdmins; - - return pipeline.update(); - } - } catch (err) { - logger.info(err.message); - } - - return Promise.resolve(); -} - -/** - * Update admins for an array of pipelines - * @param {Object} config.userFactory UserFactory - * @param {Array} config.pipelines An array of pipelines - * @param {String} config.username Username - * @param {String} config.scmContext ScmContext - * @param {PipelineFactory} config.pipelineFactory PipelineFactory object - * @return {Promise} - */ -async function batchUpdateAdmins({ userFactory, pipelines, username, scmContext, pipelineFactory }) { - await Promise.all( - pipelines.map(pipeline => updateAdmins(userFactory, username, scmContext, pipeline, pipelineFactory)) - ); -} - -/** - * Check if the PR is being restricted or not - * @method isRestrictedPR - * @param {String} restriction Is the pipeline restricting PR based on origin - * @param {String} prSource Origin of the PR - * @return {Boolean} Should the build be restricted - */ -function isRestrictedPR(restriction, prSource) { - switch (restriction) { - case 'all': - return true; - case 'branch': - case 'fork': - return prSource === restriction; - case 'none': - default: - return false; - } -} - -/** - * Stop a job by stopping all the builds associated with it - * If the build is running, set state to ABORTED - * @method stopJob - * @param {Object} config - * @param {String} config.action Event action ('Closed' or 'Synchronized') - * @param {Job} config.job Job to stop - * @param {String} config.prNum Pull request number - * @return {Promise} - */ -function stopJob({ job, prNum, action }) { - const stopRunningBuild = build => { - if (build.isDone()) { - return Promise.resolve(); - } - - const statusMessage = - action === 'Closed' - ? `Aborted because PR#${prNum} was closed` - : `Aborted because new commit was pushed to PR#${prNum}`; - - build.status = 'ABORTED'; - build.statusMessage = statusMessage; - - return build.update(); - }; - - return ( - job - .getRunningBuilds() - // Stop running builds - .then(builds => Promise.all(builds.map(stopRunningBuild))) - ); -} - -/** - * Check if the pipeline has a triggered job or not - * @method hasTriggeredJob - * @param {Pipeline} pipeline The pipeline to check - * @param {String} startFrom The trigger name - * @returns {Boolean} True if the pipeline contains the triggered job - */ -function hasTriggeredJob(pipeline, startFrom) { - try { - const nextJobs = workflowParser.getNextJobs(pipeline.workflowGraph, { - trigger: startFrom - }); - - return nextJobs.length > 0; - } catch (err) { - logger.error(`Error finding triggered jobs for ${pipeline.id}: ${err}`); - - return false; - } -} - -/** - * Check if changedFiles are under rootDir. If no custom rootDir, return true. - * @param {Object} pipeline - * @param {Array} changedFiles - * @return {Boolean} - */ -function hasChangesUnderRootDir(pipeline, changedFiles) { - const splitUri = pipeline.scmUri.split(':'); - const rootDir = splitUri.length > 3 ? splitUri[3] : ''; - const changes = changedFiles || []; - - // Only check if rootDir is set - if (rootDir) { - return changes.some(file => file.startsWith(rootDir)); - } - - return true; -} - -/** - * Resolve ChainPR flag - * @method resolveChainPR - * @param {Boolean} chainPR Plugin Chain PR flag - * @param {Pipeline} pipeline Pipeline - * @param {Object} pipeline.annotations Pipeline-level annotations - * @return {Boolean} - */ -function resolveChainPR(chainPR, pipeline) { - const defaultChainPR = typeof chainPR === 'undefined' ? false : chainPR; - const annotChainPR = pipeline.annotations[ANNOT_CHAIN_PR]; - - return typeof annotChainPR === 'undefined' ? defaultChainPR : annotChainPR; -} - -/** - * Returns an object with resolvedChainPR and skipMessage - * @param {Object} config.pipeline Pipeline - * @param {String} config.prSource The origin of this PR - * @param {String} config.restrictPR Restrict PR setting - * @param {Boolean} config.chainPR Chain PR flag - * @return {Object} - */ -function getSkipMessageAndChainPR({ pipeline, prSource, restrictPR, chainPR }) { - const defaultRestrictPR = restrictPR || 'none'; - const restriction = pipeline.annotations[ANNOT_RESTRICT_PR] || defaultRestrictPR; - const result = { - resolvedChainPR: resolveChainPR(chainPR, pipeline) - }; - - // Check for restriction upfront - if (isRestrictedPR(restriction, prSource)) { - result.skipMessage = `Skipping build since pipeline is configured to restrict ${restriction} and PR is ${prSource}`; - } - - return result; -} - -/** - * Returns the uri keeping only the host and the repo ID - * @method uriTrimmer - * @param {String} uri The uri to be trimmed - * @return {String} - */ -const uriTrimmer = uri => { - const uriToArray = uri.split(':'); - - while (uriToArray.length > 2) uriToArray.pop(); - - return uriToArray.join(':'); -}; - -/** - * Get all pipelines which has triggered job - * @method triggeredPipelines - * @param {PipelineFactory} pipelineFactory The pipeline factory to get the branch list from - * @param {Object} scmConfig Has the token and scmUri to get branches - * @param {String} branch The branch which is committed - * @param {String} type Triggered GitHub event type ('pr' or 'repo') - * @param {String} action Triggered GitHub event action - * @param {Array} changedFiles Changed files in this commit - * @param {String} releaseName SCM webhook release name - * @param {String} tagName SCM webhook tag name - * @returns {Promise} Promise that resolves into triggered pipelines - */ -async function triggeredPipelines( - pipelineFactory, - scmConfig, - branch, - type, - action, - changedFiles, - releaseName, - tagName -) { - const { scmUri } = scmConfig; - const splitUri = scmUri.split(':'); - const scmBranch = `${splitUri[0]}:${splitUri[1]}:${splitUri[2]}`; - const scmRepoId = `${splitUri[0]}:${splitUri[1]}`; - const listConfig = { search: { field: 'scmUri', keyword: `${scmRepoId}:%` } }; - const externalRepoSearchConfig = { search: { field: 'subscribedScmUrlsWithActions', keyword: `%${scmRepoId}:%` } }; - - const pipelines = await pipelineFactory.list(listConfig); - - const pipelinesWithSubscribedRepos = await pipelineFactory.list(externalRepoSearchConfig); - - let pipelinesOnCommitBranch = []; - let pipelinesOnOtherBranch = []; - - pipelines.forEach(p => { - // This uri expects 'scmUriDomain:repoId:branchName:rootDir'. To Compare, rootDir is ignored. - const splitScmUri = p.scmUri.split(':'); - const pipelineScmBranch = `${splitScmUri[0]}:${splitScmUri[1]}:${splitScmUri[2]}`; - - if (pipelineScmBranch === scmBranch) { - pipelinesOnCommitBranch.push(p); - } else { - pipelinesOnOtherBranch.push(p); - } - }); - - // Build runs regardless of changedFiles when release/tag trigger - pipelinesOnCommitBranch = pipelinesOnCommitBranch.filter( - p => ['release', 'tag'].includes(action) || hasChangesUnderRootDir(p, changedFiles) - ); - - pipelinesOnOtherBranch = pipelinesOnOtherBranch.filter(p => { - let isReleaseOrTagFiltering = ''; - - if (action === 'release' || action === 'tag') { - isReleaseOrTagFiltering = isReleaseOrTagFilteringEnabled(action, p.workflowGraph); - } - - return hasTriggeredJob( - p, - determineStartFrom(action, type, branch, null, releaseName, tagName, isReleaseOrTagFiltering) - ); - }); - - const currentRepoPipelines = pipelinesOnCommitBranch.concat(pipelinesOnOtherBranch); - - return currentRepoPipelines.concat(pipelinesWithSubscribedRepos); -} - -/** - * Create events for each pipeline - * @async createPREvents - * @param {Object} options - * @param {String} options.username User who created the PR - * @param {String} options.scmConfig Has the token and scmUri to get branches - * @param {String} options.sha Specific SHA1 commit to start the build with - * @param {String} options.prRef Reference to pull request - * @param {String} options.prNum Pull request number - * @param {String} options.prTitle Pull request title - * @param {Array} options.changedFiles List of changed files - * @param {String} options.branch The branch against which pr is opened - * @param {String} options.action Event action - * @param {String} options.prSource The origin of this PR - * @param {String} options.restrictPR Restrict PR setting - * @param {Boolean} options.chainPR Chain PR flag - * @param {Hapi.request} request Request from user - * @return {Promise} - */ -async function createPREvents(options, request) { - const { - username, - scmConfig, - prRef, - prNum, - pipelines, - prTitle, - changedFiles, - branch, - action, - prSource, - restrictPR, - chainPR, - ref, - releaseName, - meta - } = options; - const { scm } = request.server.app.pipelineFactory; - const { eventFactory, pipelineFactory, userFactory } = request.server.app; - const scmDisplayName = scm.getDisplayName({ scmContext: scmConfig.scmContext }); - const userDisplayName = `${scmDisplayName}:${username}`; - const events = []; - let { sha } = options; - - scmConfig.prNum = prNum; - - const eventConfigs = await Promise.all( - pipelines.map(async p => { - try { - const b = await p.branch; - // obtain pipeline's latest commit sha for branch specific job - let configPipelineSha = ''; - let subscribedConfigSha = ''; - let eventConfig = {}; - - // Check if the webhook event is from a subscribed repo and - // and fetch the source repo commit sha and save the subscribed sha - if (uriTrimmer(scmConfig.scmUri) !== uriTrimmer(p.scmUri)) { - subscribedConfigSha = sha; - - try { - sha = await pipelineFactory.scm.getCommitSha({ - scmUri: p.scmUri, - scmContext: scmConfig.scmContext, - token: scmConfig.token - }); - } catch (err) { - if (err.status >= 500) { - throw err; - } else { - logger.info(`skip create event for branch: ${b}`); - } - } - - configPipelineSha = sha; - } else { - try { - configPipelineSha = await pipelineFactory.scm.getCommitSha(scmConfig); - } catch (err) { - if (err.status >= 500) { - throw err; - } else { - logger.info(`skip create event for branch: ${b}`); - } - } - } - - const { skipMessage, resolvedChainPR } = getSkipMessageAndChainPR({ - // Workaround for pipelines which has NULL value in `pipeline.annotations` - pipeline: !p.annotations ? { annotations: {}, ...p } : p, - prSource, - restrictPR, - chainPR - }); - - const prInfo = await eventFactory.scm.getPrInfo(scmConfig); - - eventConfig = { - pipelineId: p.id, - type: 'pr', - webhooks: true, - username, - scmContext: scmConfig.scmContext, - sha, - configPipelineSha, - startFrom: `~pr:${branch}`, - changedFiles, - causeMessage: `${action} by ${userDisplayName}`, - chainPR: resolvedChainPR, - prRef, - prNum, - prTitle, - prInfo, - prSource, - baseBranch: branch - }; - - if (b === branch) { - eventConfig.startFrom = '~pr'; - } - - // Check if the webhook event is from a subscribed repo and - // set the jobs entrypoint from ~startFrom - // For subscribed PR event, it should be mimicked as a commit - // in order to function properly - if (uriTrimmer(scmConfig.scmUri) !== uriTrimmer(p.scmUri)) { - eventConfig = { - pipelineId: p.id, - type: 'pipeline', - webhooks: true, - username, - scmContext: scmConfig.scmContext, - startFrom: '~subscribe', - sha, - configPipelineSha, - changedFiles, - baseBranch: branch, - causeMessage: `Merged by ${username}`, - meta, - releaseName, - ref, - subscribedEvent: true, - subscribedConfigSha, - subscribedSourceUrl: prInfo.url - }; - - await updateAdmins(userFactory, username, scmConfig.scmContext, p.id, pipelineFactory); - } - - if (skipMessage) { - eventConfig.skipMessage = skipMessage; - } - - return eventConfig; - } catch (err) { - logger.warn(`pipeline:${p.id} error in starting event`, err); - - return null; - } - }) - ); - - eventConfigs.forEach(eventConfig => { - if (eventConfig && eventConfig.configPipelineSha) { - events.push(eventFactory.create(eventConfig)); - } - }); - - return Promise.all(events); -} - -/** - * Stop all the relevant PR jobs for an array of pipelines - * @async batchStopJobs - * @param {Array} config.pipelines An array of pipeline - * @param {Integer} config.prNum PR number - * @param {String} config.action Event action - * @param {String} config.name Prefix of the PR job name: PR-prNum - */ -async function batchStopJobs({ pipelines, prNum, action, name }) { - const prJobs = await Promise.all( - pipelines.map(p => p.getJobs({ type: 'pr' }).then(jobs => jobs.filter(j => j.name.includes(name)))) - ); - const flatPRJobs = prJobs.reduce((prev, curr) => prev.concat(curr)); - - await Promise.all(flatPRJobs.map(j => stopJob({ job: j, prNum, action }))); -} - -/** - * Create a new job and start the build for an opened pull-request - * @async pullRequestOpened - * @param {Object} options - * @param {String} options.hookId Unique ID for this scm event - * @param {String} options.prSource The origin of this PR - * @param {Pipeline} options.pipeline Pipeline model for the pr - * @param {String} options.restrictPR Restrict PR setting - * @param {Boolean} options.chainPR Chain PR flag - * @param {Hapi.request} request Request from user - * @param {Hapi.h} h Response toolkit - */ -async function pullRequestOpened(options, request, h) { - const { hookId } = options; - - return createPREvents(options, request) - .then(events => { - events.forEach(e => { - request.log(['webhook', hookId, e.id], `Event ${e.id} started`); - }); - - return h.response().code(201); - }) - .catch(err => { - logger.error( - `Failed to pullRequestOpened: [${hookId}, pipeline:${options.pipeline && options.pipeline.id}]: ${err}` - ); - - throw err; - }); -} - -/** - * Stop any running builds and disable the job for closed pull-request - * @async pullRequestClosed - * @param {Object} options - * @param {String} options.hookId Unique ID for this scm event - * @param {Pipeline} options.pipeline Pipeline model for the pr - * @param {String} options.name Name of the PR: PR-prNum - * @param {String} options.prNum Pull request number - * @param {String} options.action Event action - * @param {String} options.fullCheckoutUrl CheckoutUrl with branch name - * @param {Hapi.request} request Request from user - * @param {Hapi.reply} reply Reply to user - */ -async function pullRequestClosed(options, request, h) { - const { pipelines, hookId, name, prNum, action } = options; - const updatePRJobs = job => - stopJob({ job, prNum, action }) - .then(() => request.log(['webhook', hookId, job.id], `${job.name} stopped`)) - .then(() => { - job.archived = true; - - return job.update(); - }) - .then(() => request.log(['webhook', hookId, job.id], `${job.name} disabled and archived`)); - - return Promise.all( - pipelines.map(p => - p.getJobs({ type: 'pr' }).then(jobs => { - const prJobs = jobs.filter(j => j.name.includes(name)); - - return Promise.all(prJobs.map(j => updatePRJobs(j))); - }) - ) - ) - .then(() => h.response().code(200)) - .catch(err => { - logger.error( - `Failed to pullRequestClosed: [${hookId}, pipeline:${options.pipeline && options.pipeline.id}]: ${err}` - ); - - throw err; - }); -} - -/** - * Stop any running builds and start the build for the synchronized pull-request - * @async pullRequestSync - * @param {Object} options - * @param {String} options.hookId Unique ID for this scm event - * @param {String} options.name Name of the new job (PR-1) - * @param {String} options.prSource The origin of this PR - * @param {String} options.restrictPR Restrict PR setting - * @param {Boolean} options.chainPR Chain PR flag - * @param {Pipeline} options.pipeline Pipeline model for the pr - * @param {Array} options.changedFiles List of files that were changed - * @param {String} options.prNum Pull request number - * @param {String} options.action Event action - * @param {Hapi.request} request Request from user - * @param {Hapi.reply} reply Reply to user - */ -async function pullRequestSync(options, request, h) { - const { pipelines, hookId, name, prNum, action } = options; - - await batchStopJobs({ pipelines, name, prNum, action }); - - request.log(['webhook', hookId], `Job(s) for ${name} stopped`); - - return createPREvents(options, request) - .then(events => { - events.forEach(e => { - request.log(['webhook', hookId, e.id], `Event ${e.id} started`); - }); - - return h.response().code(201); - }) - .catch(err => { - logger.error( - `Failed to pullRequestSync: [${hookId}, pipeline:${options.pipeline && options.pipeline.id}]: ${err}` - ); - - throw err; - }); -} - -/** - * Obtains the SCM token for a given user. - * If a user does not have a valid SCM token registered with Screwdriver, - * it will use a generic user's token instead. - * If pipeline is in read-only SCM, use read-only token. - * Some SCM services have different thresholds between IP requests and token requests. This is - * to ensure we have a token to access the SCM service without being restricted by these quotas - * @method obtainScmToken - * @param {Object} pluginOptions - * @param {String} pluginOptions.username Generic scm username - * @param {UserFactory} userFactory UserFactory object - * @param {String} username Name of the user that the SCM token is associated with - * @param {String} scmContext Scm which pipeline's repository exists in - * @param {Object} scm Scm - * @return {Promise} Promise that resolves into a SCM token - */ -async function obtainScmToken({ pluginOptions, userFactory, username, scmContext, scm }) { - const { readOnlyEnabled, headlessAccessToken } = getReadOnlyInfo({ scm, scmContext }); - - // If pipeline is in read-only SCM, use read-only token - if (readOnlyEnabled && headlessAccessToken) { - return headlessAccessToken; - } - - const user = await userFactory.get({ username, scmContext }); - - // Use generic username and token - if (!user) { - const genericUsername = pluginOptions.username; - const buildBotUser = await userFactory.get({ username: genericUsername, scmContext }); - - return buildBotUser.unsealToken(); - } - - return user.unsealToken(); -} - -/** - * Create metadata by the parsed event - * @param {Object} parsed It has information to create metadata - * @returns {Object} Metadata - */ -function createMeta(parsed) { - const { action, ref, releaseId, releaseName, releaseAuthor } = parsed; - - if (action === 'release') { - return { - sd: { - release: { - id: releaseId, - name: releaseName, - author: releaseAuthor - }, - tag: { - name: ref - } - } - }; - } - if (action === 'tag') { - return { - sd: { - tag: { - name: ref - } - } - }; - } - - return {}; -} - -/** - * Act on a Pull Request change (create, sync, close) - * - Opening a PR should sync the pipeline (creating the job) and start the new PR job - * - Syncing a PR should stop the existing PR job and start a new one - * - Closing a PR should stop the PR job and sync the pipeline (disabling the job) - * @method pullRequestEvent - * @param {Object} pluginOptions - * @param {String} pluginOptions.username Generic scm username - * @param {String} pluginOptions.restrictPR Restrict PR setting - * @param {Boolean} pluginOptions.chainPR Chain PR flag - * @param {Hapi.request} request Request from user - * @param {Hapi.reply} reply Reply to user - * @param {String} token The token used to authenticate to the SCM - * @param {Object} parsed - */ -function pullRequestEvent(pluginOptions, request, h, parsed, token) { - const { pipelineFactory, userFactory } = request.server.app; - const { - hookId, - action, - checkoutUrl, - branch, - sha, - prNum, - prTitle, - prRef, - prSource, - username, - scmContext, - changedFiles, - type, - releaseName, - ref - } = parsed; - const fullCheckoutUrl = `${checkoutUrl}#${branch}`; - const scmConfig = { - scmUri: '', - token, - scmContext - }; - const { restrictPR, chainPR } = pluginOptions; - const meta = createMeta(parsed); - - request.log(['webhook', hookId], `PR #${prNum} ${action} for ${fullCheckoutUrl}`); - - return pipelineFactory.scm - .parseUrl({ - checkoutUrl: fullCheckoutUrl, - token, - scmContext - }) - .then(scmUri => { - scmConfig.scmUri = scmUri; - - return triggeredPipelines(pipelineFactory, scmConfig, branch, type, action, changedFiles, releaseName, ref); - }) - .then(async pipelines => { - if (!pipelines || pipelines.length === 0) { - const message = `Skipping since Pipeline triggered by PRs against ${fullCheckoutUrl} does not exist`; - - request.log(['webhook', hookId], message); - - return h.response({ message }).code(204); - } - - const options = { - name: `PR-${prNum}`, - hookId, - sha, - username, - scmConfig, - prRef, - prNum, - prTitle, - prSource, - changedFiles, - action: action.charAt(0).toUpperCase() + action.slice(1), - branch, - fullCheckoutUrl, - restrictPR, - chainPR, - pipelines, - ref, - releaseName, - meta - }; - - await batchUpdateAdmins({ userFactory, pipelines, username, scmContext, pipelineFactory }); - - switch (action) { - case 'opened': - case 'reopened': - return pullRequestOpened(options, request, h); - case 'synchronized': - return pullRequestSync(options, request, h); - case 'closed': - default: - return pullRequestClosed(options, request, h); - } - }) - .catch(err => { - logger.error(`[${hookId}]: ${err}`); - - throw err; - }); -} - -/** - * Create events for each pipeline - * @async createEvents - * @param {EventFactory} eventFactory To create event - * @param {UserFactory} userFactory To get user permission - * @param {PipelineFactory} pipelineFactory To use scm module - * @param {Array} pipelines The pipelines to start events - * @param {Object} parsed It has information to create event - * @param {String} [skipMessage] Message to skip starting builds - * @returns {Promise} Promise that resolves into events - */ -async function createEvents( - eventFactory, - userFactory, - pipelineFactory, - pipelines, - parsed, - skipMessage, - scmConfigFromHook -) { - const { action, branch, sha, username, scmContext, changedFiles, type, releaseName, ref } = parsed; - const events = []; - const meta = createMeta(parsed); - - const pipelineTuples = await Promise.all( - pipelines.map(async p => { - const resolvedBranch = await p.branch; - let isReleaseOrTagFiltering = ''; - - if (action === 'release' || action === 'tag') { - isReleaseOrTagFiltering = isReleaseOrTagFilteringEnabled(action, p.workflowGraph); - } - const startFrom = determineStartFrom( - action, - type, - branch, - resolvedBranch, - releaseName, - ref, - isReleaseOrTagFiltering - ); - const tuple = { branch: resolvedBranch, pipeline: p, startFrom }; - - return tuple; - }) - ); - - const ignoreExtraTriggeredPipelines = pipelineTuples.filter(t => { - // empty event is not created when it is triggered by extra triggers (e.g. ~tag, ~release) - if (EXTRA_TRIGGERS.test(t.startFrom) && !hasTriggeredJob(t.pipeline, t.startFrom)) { - logger.warn(`Event not created: there are no jobs triggered by ${t.startFrom}`); - - return false; - } - - return true; - }); - - const eventConfigs = await Promise.all( - ignoreExtraTriggeredPipelines.map(async pTuple => { - try { - const pipelineBranch = pTuple.branch; - let isReleaseOrTagFiltering = ''; - - if (action === 'release' || action === 'tag') { - isReleaseOrTagFiltering = isReleaseOrTagFilteringEnabled(action, pTuple.pipeline.workflowGraph); - } - const startFrom = determineStartFrom( - action, - type, - branch, - pipelineBranch, - releaseName, - ref, - isReleaseOrTagFiltering - ); - const token = await pTuple.pipeline.token; - const scmConfig = { - scmUri: pTuple.pipeline.scmUri, - token, - scmContext - }; - // obtain pipeline's latest commit sha for branch specific job - let configPipelineSha = ''; - - try { - configPipelineSha = await pipelineFactory.scm.getCommitSha(scmConfig); - } catch (err) { - if (err.status >= 500) { - throw err; - } else { - logger.info(`skip create event for branch: ${pipelineBranch}`); - } - } - const eventConfig = { - pipelineId: pTuple.pipeline.id, - type: 'pipeline', - webhooks: true, - username, - scmContext, - startFrom, - sha, - configPipelineSha, - changedFiles, - baseBranch: branch, - causeMessage: `Merged by ${username}`, - meta, - releaseName, - ref - }; - - // Check is the webhook event is from a subscribed repo and - // set the jobs entry point to ~subscribe - if (uriTrimmer(scmConfigFromHook.scmUri) !== uriTrimmer(pTuple.pipeline.scmUri)) { - eventConfig.subscribedEvent = true; - eventConfig.startFrom = '~subscribe'; - eventConfig.subscribedConfigSha = eventConfig.sha; - - try { - eventConfig.sha = await pipelineFactory.scm.getCommitSha(scmConfig); - } catch (err) { - if (err.status >= 500) { - throw err; - } else { - logger.info(`skip create event for this subscribed trigger`); - } - } - - try { - const commitInfo = await pipelineFactory.scm.decorateCommit({ - scmUri: scmConfigFromHook.scmUri, - scmContext, - sha: eventConfig.subscribedConfigSha, - token - }); - - eventConfig.subscribedSourceUrl = commitInfo.url; - } catch (err) { - if (err.status >= 500) { - throw err; - } else { - logger.info(`skip create event for this subscribed trigger`); - } - } - } - - if (skipMessage) { - eventConfig.skipMessage = skipMessage; - } - - await updateAdmins(userFactory, username, scmContext, pTuple.pipeline, pipelineFactory); - - return eventConfig; - } catch (err) { - logger.warn(`pipeline:${pTuple.pipeline.id} error in starting event`, err); - - return null; - } - }) - ); - - eventConfigs.forEach(eventConfig => { - if (eventConfig && eventConfig.configPipelineSha) { - events.push(eventFactory.create(eventConfig)); - } - }); - - return Promise.all(events); -} - -/** - * Act on a Push event - * - Should start a new main job - * @method pushEvent - * @param {Hapi.request} request Request from user - * @param {Hapi.h} h Response toolkit - * @param {Object} parsed It has information to create event - * @param {String} token The token used to authenticate to the SCM - * @param {String} [skipMessage] Message to skip starting builds - */ -async function pushEvent(request, h, parsed, skipMessage, token) { - const { eventFactory, pipelineFactory, userFactory } = request.server.app; - const { hookId, checkoutUrl, branch, scmContext, type, action, changedFiles, releaseName, ref } = parsed; - const fullCheckoutUrl = `${checkoutUrl}#${branch}`; - const scmConfig = { - scmUri: '', - token: '', - scmContext - }; - - request.log(['webhook', hookId], `Push for ${fullCheckoutUrl}`); - - try { - scmConfig.token = token; - scmConfig.scmUri = await pipelineFactory.scm.parseUrl({ - checkoutUrl: fullCheckoutUrl, - token, - scmContext - }); - - const pipelines = await triggeredPipelines( - pipelineFactory, - scmConfig, - branch, - type, - action, - changedFiles, - releaseName, - ref - ); - let events = []; - - if (!pipelines || pipelines.length === 0) { - request.log(['webhook', hookId], `Skipping since Pipeline ${fullCheckoutUrl} does not exist`); - } else { - events = await createEvents( - eventFactory, - userFactory, - pipelineFactory, - pipelines, - parsed, - skipMessage, - scmConfig - ); - } - - const hasBuildEvents = events.filter(e => e.builds !== null); - - if (hasBuildEvents.length === 0) { - return h.response({ message: 'No jobs to start' }).code(204); - } - - hasBuildEvents.forEach(e => { - request.log(['webhook', hookId, e.id], `Event ${e.id} started`); - }); - - return h.response().code(201); - } catch (err) { - logger.error(`[${hookId}]: ${err}`); - - throw err; - } -} - -/** Execute scm.getCommitRefSha() - * @method getCommitRefSha - * @param {Object} scm - * @param {String} token The token used to authenticate to the SCM - * @param {String} ref The reference which we want - * @param {String} checkoutUrl Scm checkout URL - * @param {String} scmContext Scm which pipeline's repository exists in - * @returns {Promise} Specific SHA1 commit to start the build with - */ -async function getCommitRefSha({ scm, token, ref, refType, checkoutUrl, scmContext }) { - // For example, git@github.com:screwdriver-cd/data-schema.git => screwdriver-cd, data-schema - const owner = CHECKOUT_URL_SCHEMA_REGEXP.exec(checkoutUrl)[2]; - const repo = CHECKOUT_URL_SCHEMA_REGEXP.exec(checkoutUrl)[3]; - - return scm.getCommitRefSha({ - token, - owner, - repo, - ref, - refType, - scmContext - }); -} - /** * Webhook API Plugin * - Validates that webhook events came from the specified scm provider @@ -1156,12 +62,9 @@ const webhooksPlugin = { maxBytes: parseInt(pluginOptions.maxBytes, 10) || DEFAULT_MAX_BYTES }, handler: async (request, h) => { - const { userFactory, pipelineFactory } = request.server.app; + const { pipelineFactory } = request.server.app; const { scm } = pipelineFactory; - const ignoreUser = pluginOptions.ignoreCommitsBy; let message = 'Unable to process this kind of event'; - let skipMessage; - let parsedHookId = ''; try { const parsed = await scm.parseHook(request.headers, request.payload); @@ -1171,77 +74,16 @@ const webhooksPlugin = { return h.response({ message }).code(204); } - const { type, hookId, username, scmContext, ref, checkoutUrl, action, prNum } = parsed; + parsed.pluginOptions = pluginOptions; - parsedHookId = hookId; + const { type, hookId } = parsed; request.log(['webhook', hookId], `Received event type ${type}`); - // skipping checks - if (/\[(skip ci|ci skip)\]/.test(parsed.lastCommitMessage)) { - skipMessage = 'Skipping due to the commit message: [skip ci]'; - } - - // if skip ci then don't return - if (ignoreUser && ignoreUser.length !== 0 && !skipMessage) { - const commitAuthors = - Array.isArray(parsed.commitAuthors) && parsed.commitAuthors.length !== 0 - ? parsed.commitAuthors - : [username]; - const validCommitAuthors = commitAuthors.filter(author => !ignoreUser.includes(author)); - - if (!validCommitAuthors.length) { - message = `Skipping because user ${username} is ignored`; - request.log(['webhook', hookId], message); - - return h.response({ message }).code(204); - } - } - - const token = await obtainScmToken({ pluginOptions, userFactory, username, scmContext, scm }); - - if (action !== 'release' && action !== 'tag') { - let scmUri; - - if (type === 'pr') { - scmUri = await scm.parseUrl({ checkoutUrl, token, scmContext }); - } - parsed.changedFiles = await scm.getChangedFiles({ - payload: request.payload, - type, - token, - scmContext, - scmUri, - prNum - }); - request.log(['webhook', hookId], `Changed files are ${parsed.changedFiles}`); - } else { - // The payload has no sha when webhook event is tag or release, so we need to get it. - try { - parsed.sha = await getCommitRefSha({ - scm, - token, - ref, - refType: 'tags', - checkoutUrl, - scmContext - }); - } catch (err) { - request.log(['webhook', hookId, 'getCommitRefSha'], err); - - // there is a possibility of scm.getCommitRefSha() is not implemented yet - return h.response({ message }).code(204); - } - } - - if (type === 'pr') { - // disregard skip ci for pull request events - return pullRequestEvent(pluginOptions, request, h, parsed, token); - } + return await startHookEvent(request, h, parsed); - return pushEvent(request, h, parsed, skipMessage, token); } catch (err) { - logger.error(`[${parsedHookId}]: ${err}`); + logger.error(`[${hookId}]: ${err}`); throw err; } diff --git a/test/lib/registerPlugins.test.js b/test/lib/registerPlugins.test.js index a97c57737..2f1cc3cf0 100644 --- a/test/lib/registerPlugins.test.js +++ b/test/lib/registerPlugins.test.js @@ -39,7 +39,8 @@ describe('Register Unit Test Case', () => { '../plugins/isAdmin', '../plugins/shutdown', '../plugins/release', - '../plugins/validator' + '../plugins/validator', + '../plugins/processHooks' ]; const authPlugins = ['@hapi/bell', '@hapi/cookie', '@hapi/crumb', 'hapi-auth-bearer-token', 'hapi-auth-jwt2']; const pluginLength = expectedPlugins.length + resourcePlugins.length + authPlugins.length; // for server.register of auth Plugins; diff --git a/test/plugins/data/webhookConfigPush.json b/test/plugins/data/webhookConfigPush.json new file mode 100644 index 000000000..b23224a8f --- /dev/null +++ b/test/plugins/data/webhookConfigPush.json @@ -0,0 +1,31 @@ +{ + "action": "push", + "branch": "master", + "checkoutUrl": "git@github.com:baxterthehacker/public-repo.git", + "sha": "0d1a26e67d8f5eaf1f6ba5c57fc3c7d91ac0fd1c", + "type": "repo", + "username": "baxterthehacker2", + "commitAuthors": [ + "baxterthehacker" + ], + "lastCommitMessage": "lastcommitmessage", + "hookId": "3c77bf80-9a2f-11e6-80d6-72f7fe03ea29", + "scmContext": "github:github.com", + "ref": "refs/heads/master", + "addedFiles": [ + "README.md" + ], + "modifiedFiles": [ + "README.md", + "package.json" + ], + "removedFiles": [ + "screwdriver.yaml" + ], + "pluginOptions": { + "username": "sd-buildbot", + "ignoreCommitsBy": ["batman", "superman"], + "restrictPR": "fork", + "chainPR": false + } +} diff --git a/test/plugins/processHooks.test.js b/test/plugins/processHooks.test.js new file mode 100644 index 000000000..17277d845 --- /dev/null +++ b/test/plugins/processHooks.test.js @@ -0,0 +1,236 @@ +'use strict'; + +const chai = require('chai'); +const sinon = require('sinon'); +const hapi = require('@hapi/hapi'); +const mockery = require('mockery'); +const rewire = require('rewire'); +const { assert } = chai; +const hoek = require('@hapi/hoek'); + +chai.use(require('chai-as-promised')); + +sinon.assert.expose(assert, { prefix: '' }); + +const testWebhookConfigPush = require('./data/webhookConfigPush.json'); + +describe('processHooks plugin test', () => { + let jobFactoryMock; + let buildFactoryMock; + let pipelineFactoryMock; + let userFactoryMock; + let eventFactoryMock; + let userMock; + let pipelineMock; + let plugin; + let server; + let mainJobMock; + let jobMock; + let workflowGraph; + const scmUri = 'github.com:123456:master'; + const pipelineId = 'pipelineHash'; + const jobId = 2; + const apiUri = 'http://foo.bar:12345'; + const latestSha = 'a402964c054c610757794d9066c96cee1772daed'; + const sha = '0d1a26e67d8f5eaf1f6ba5c57fc3c7d91ac0fd1c'; + const eventMock = { + id: 'bbf22a3808c19dc50777258a253805b14fb3ad8b' + }; + const fullCheckoutUrl = 'git@github.com:baxterthehacker/public-repo.git#master'; + const scmContext = 'github:github.com'; + const token = 'iamtoken'; + const username = 'baxterthehacker2'; + const changedFiles = ['README.md', 'package.json', 'screwdriver.yaml']; + const ref = 'refs/heads/master'; + const decoratePipelineMock = pipeline => { + const decorated = hoek.clone(pipeline); + + decorated.sync = sinon.stub(); + decorated.getConfiguration = sinon.stub(); + decorated.getJobs = sinon.stub().resolves([mainJobMock, jobMock]); + decorated.update = sinon.stub(); + decorated.branch = pipeline.branch; + + return decorated; + }; + const getPipelineMocks = p => { + if (Array.isArray(p)) { + return p.map(decoratePipelineMock); + } + + return decoratePipelineMock(p); + }; + + before(() => { + mockery.enable({ + useCleanCache: true, + warnOnUnregistered: false + }); + }); + + beforeEach(async () => { + const name = 'PR-1'; + mainJobMock = { + id: 1, + name: 'main', + state: 'ENABLED', + update: sinon.stub(), + getRunningBuilds: sinon.stub() + }; + jobMock = { + id: jobId, + name, + state: 'ENABLED', + update: sinon.stub(), + getRunningBuilds: sinon.stub() + }; + workflowGraph = { + nodes: [{ name: '~pr' }, { name: '~commit' }, { name: 'main' }], + edges: [ + { src: '~pr', dest: 'main' }, + { src: '~commit', dest: 'main' } + ] + }; + jobFactoryMock = { + get: sinon.stub(), + create: sinon.stub() + }; + buildFactoryMock = { + create: sinon.stub() + }; + pipelineFactoryMock = { + get: sinon.stub(), + list: sinon.stub(), + scm: { + parseHook: sinon.stub(), + parseUrl: sinon.stub(), + getDisplayName: sinon.stub(), + getChangedFiles: sinon.stub(), + getCommitSha: sinon.stub(), + getCommitRefSha: sinon.stub(), + getReadOnlyInfo: sinon.stub().returns({ enabled: false }) + } + }; + userFactoryMock = { + get: sinon.stub() + }; + eventFactoryMock = { + scm: { + getPrInfo: sinon.stub() + }, + create: sinon.stub() + }; + userMock = { + unsealToken: sinon.stub(), + getPermissions: sinon.stub().resolves({ + push: true + }) + }; + pipelineMock = getPipelineMocks({ + id: pipelineId, + scmUri, + annotations: {}, + admins: { + baxterthehacker: false + }, + workflowGraph, + branch: Promise.resolve('master') + }); + + plugin = rewire('../../plugins/processHooks'); + + server = new hapi.Server({ + host: 'localhost', + port: 12345, + uri: apiUri + }); + server.app = { + jobFactory: jobFactoryMock, + buildFactory: buildFactoryMock, + pipelineFactory: pipelineFactoryMock, + userFactory: userFactoryMock, + eventFactory: eventFactoryMock + }; + + server.auth.scheme('custom', () => ({ + authenticate: (request, h) => + h.authenticated({ + credentials: { + scope: ['webhook_worker'] + } + }) + })); + server.auth.strategy('token', 'custom'); + + await server.register({ + plugin, + options: {} + }); + server.app.buildFactory.apiUri = apiUri; + server.app.buildFactory.tokenGen = buildId => + JSON.stringify({ + username: buildId, + scope: ['temporal'] + }); + + userFactoryMock.get.resolves(userMock); + userMock.unsealToken.resolves(token); + pipelineFactoryMock.scm.parseUrl + .withArgs({ checkoutUrl: fullCheckoutUrl, token, scmContext }) + .resolves('github.com:123456:master'); + pipelineFactoryMock.list.resolves([pipelineMock]); + pipelineFactoryMock.scm.getCommitSha.resolves(latestSha); + pipelineFactoryMock.scm.getChangedFiles.resolves(changedFiles); + eventFactoryMock.create.resolves(eventMock); + }); + + afterEach(() => { + server = null; + mockery.deregisterAll(); + mockery.resetCache(); + }); + + after(() => { + mockery.disable(); + }); + + it('registers the plugin', () => { + assert.isOk(server.registrations.processHooks); + }); + + it('returns 201 on success', () => { + const options = { + method: 'POST', + url: '/processHooks', + headers: {}, + auth: { + credentials: { + scope: ['webhook_worker'] + }, + strategy: 'token' + }, + payload: testWebhookConfigPush + }; + + return server.inject(options).then(reply => { + assert.equal(reply.statusCode, 201); + assert.notCalled(pipelineFactoryMock.scm.getCommitRefSha); + assert.calledWith(eventFactoryMock.create, { + pipelineId, + type: 'pipeline', + webhooks: true, + username, + scmContext, + sha, + configPipelineSha: latestSha, + startFrom: '~commit', + baseBranch: 'master', + causeMessage: `Merged by ${username}`, + changedFiles, + releaseName: undefined, + ref, + meta: {} + }); + }); + }); +}); diff --git a/test/plugins/webhooks.helper.test.js b/test/plugins/webhooks.helper.test.js new file mode 100644 index 000000000..8f1587945 --- /dev/null +++ b/test/plugins/webhooks.helper.test.js @@ -0,0 +1,250 @@ +'use strict'; + +const chai = require('chai'); +const sinon = require('sinon'); +const rewire = require('rewire'); +const { assert } = chai; + +chai.use(require('chai-as-promised')); + +const RewiredWebhooksHelper = rewire('../../plugins/webhooks/helper.js'); +/* eslint-disable no-underscore-dangle */ +const ANNOT_CHAIN_PR = RewiredWebhooksHelper.__get__('ANNOT_CHAIN_PR'); + +sinon.assert.expose(assert, { prefix: '' }); + +describe('determineStartFrom function', () => { + // eslint-disable-next-line no-underscore-dangle + const determineStartFrom = RewiredWebhooksHelper.__get__('determineStartFrom'); + let action; + let type; + let targetBranch; + let pipelineBranch; + let releaseName; + let tagName; + let isReleaseOrTagFiltering; + + beforeEach(() => { + action = 'push'; + type = 'repo'; + targetBranch = 'master'; + pipelineBranch = 'master'; + releaseName = ''; + tagName = 'v1'; + isReleaseOrTagFiltering = false; + }); + + it('determines to "~commit" when action is "push"', () => { + assert.equal( + determineStartFrom( + action, + type, + targetBranch, + pipelineBranch, + releaseName, + tagName, + isReleaseOrTagFiltering + ), + '~commit' + ); + }); + + it('determines to "~commit:branch" when action is "push" and targetBranch is branch', () => { + targetBranch = 'branch'; + + assert.equal( + determineStartFrom( + action, + type, + targetBranch, + pipelineBranch, + releaseName, + tagName, + isReleaseOrTagFiltering + ), + '~commit:branch' + ); + }); + + it('determines to "~pr" when type is "pr"', () => { + type = 'pr'; + + assert.equal( + determineStartFrom( + action, + type, + targetBranch, + pipelineBranch, + releaseName, + tagName, + isReleaseOrTagFiltering + ), + '~pr' + ); + }); + + it('determines to "~pr:branch" when type is "pr" and targetBranch is branch', () => { + type = 'pr'; + targetBranch = 'branch'; + + assert.equal( + determineStartFrom( + action, + type, + targetBranch, + pipelineBranch, + releaseName, + tagName, + isReleaseOrTagFiltering + ), + '~pr:branch' + ); + }); + + it('determines to "~release" when action is "release"', () => { + action = 'release'; + isReleaseOrTagFiltering = false; + + assert.equal( + determineStartFrom( + action, + type, + targetBranch, + pipelineBranch, + releaseName, + tagName, + isReleaseOrTagFiltering + ), + '~release' + ); + }); + + it('determines to "~release" when action is "release" even targetBranch is branch', () => { + action = 'release'; + targetBranch = 'branch'; + isReleaseOrTagFiltering = false; + + assert.equal( + determineStartFrom( + action, + type, + targetBranch, + pipelineBranch, + releaseName, + tagName, + isReleaseOrTagFiltering + ), + '~release' + ); + }); + + it('determines to "~release:releaseName" when filter the release trigger', () => { + action = 'release'; + releaseName = 'releaseName'; + isReleaseOrTagFiltering = true; + + assert.equal( + determineStartFrom( + action, + type, + targetBranch, + pipelineBranch, + releaseName, + tagName, + isReleaseOrTagFiltering + ), + '~release:releaseName' + ); + }); + + it('determines to "~tag" when action is "tag"', () => { + action = 'tag'; + isReleaseOrTagFiltering = false; + + assert.equal( + determineStartFrom( + action, + type, + targetBranch, + pipelineBranch, + releaseName, + tagName, + isReleaseOrTagFiltering + ), + '~tag' + ); + }); + + it('determines to "~tag" when action is "tag" even targetBranch is branch', () => { + action = 'tag'; + targetBranch = 'branch'; + isReleaseOrTagFiltering = false; + + assert.equal( + determineStartFrom( + action, + type, + targetBranch, + pipelineBranch, + releaseName, + tagName, + isReleaseOrTagFiltering + ), + '~tag' + ); + }); + + it('determines to "~tag:tagName" when filter the tag trigger', () => { + action = 'tag'; + tagName = 'tagName'; + isReleaseOrTagFiltering = true; + + assert.equal( + determineStartFrom( + action, + type, + targetBranch, + pipelineBranch, + releaseName, + tagName, + isReleaseOrTagFiltering + ), + '~tag:tagName' + ); + }); +}); + +describe('resolveChainPR function', () => { + it('resolves ChainPR flag', () => { + // eslint-disable-next-line no-underscore-dangle + const resolveChainPR = RewiredWebhooksHelper.__get__('resolveChainPR'); + + let chainPR; // undefined; + const pipeline = { + annotations: {} + }; + + pipeline.annotations[ANNOT_CHAIN_PR] = undefined; + assert.isFalse(resolveChainPR(chainPR, pipeline)); + pipeline.annotations[ANNOT_CHAIN_PR] = true; + assert.isTrue(resolveChainPR(chainPR, pipeline)); + pipeline.annotations[ANNOT_CHAIN_PR] = false; + assert.isFalse(resolveChainPR(chainPR, pipeline)); + + chainPR = true; + pipeline.annotations[ANNOT_CHAIN_PR] = undefined; + assert.isTrue(resolveChainPR(chainPR, pipeline)); + pipeline.annotations[ANNOT_CHAIN_PR] = true; + assert.isTrue(resolveChainPR(chainPR, pipeline)); + pipeline.annotations[ANNOT_CHAIN_PR] = false; + assert.isFalse(resolveChainPR(chainPR, pipeline)); + + chainPR = false; + pipeline.annotations[ANNOT_CHAIN_PR] = undefined; + assert.isFalse(resolveChainPR(chainPR, pipeline)); + pipeline.annotations[ANNOT_CHAIN_PR] = true; + assert.isTrue(resolveChainPR(chainPR, pipeline)); + pipeline.annotations[ANNOT_CHAIN_PR] = false; + assert.isFalse(resolveChainPR(chainPR, pipeline)); + }); +}); diff --git a/test/plugins/webhooks.test.js b/test/plugins/webhooks.test.js index b52fa56b4..654f8ee79 100644 --- a/test/plugins/webhooks.test.js +++ b/test/plugins/webhooks.test.js @@ -10,10 +10,9 @@ const hoek = require('@hapi/hoek'); chai.use(require('chai-as-promised')); -const RewiredWebhooks = rewire('../../plugins/webhooks'); +const RewiredWebhooksHelper = rewire('../../plugins/webhooks/helper.js'); /* eslint-disable no-underscore-dangle */ -const ANNOT_CHAIN_PR = RewiredWebhooks.__get__('ANNOT_CHAIN_PR'); -const ANNOT_RESTRICT_PR = RewiredWebhooks.__get__('ANNOT_RESTRICT_PR'); +const ANNOT_RESTRICT_PR = RewiredWebhooksHelper.__get__('ANNOT_RESTRICT_PR'); /* eslint-enable no-underscore-dangle */ const testPayloadPush = require('./data/github.push.json'); @@ -26,209 +25,6 @@ const PARSED_CONFIG = require('./data/github.parsedyaml.json'); sinon.assert.expose(assert, { prefix: '' }); -// separate from "webhooks plugin test" because there is unnecessary beforeEach hook for test test case -describe('webhooks.determineStartFrom', () => { - const webhooks = rewire('../../plugins/webhooks/index.js'); - // eslint-disable-next-line no-underscore-dangle - const determineStartFrom = webhooks.__get__('determineStartFrom'); - let action; - let type; - let targetBranch; - let pipelineBranch; - let releaseName; - let tagName; - let isReleaseOrTagFiltering; - - beforeEach(() => { - action = 'push'; - type = 'repo'; - targetBranch = 'master'; - pipelineBranch = 'master'; - releaseName = ''; - tagName = 'v1'; - isReleaseOrTagFiltering = false; - }); - - it('determines to "~commit" when action is "push"', () => { - assert.equal( - determineStartFrom( - action, - type, - targetBranch, - pipelineBranch, - releaseName, - tagName, - isReleaseOrTagFiltering - ), - '~commit' - ); - }); - - it('determines to "~commit:branch" when action is "push" and targetBranch is branch', () => { - targetBranch = 'branch'; - - assert.equal( - determineStartFrom( - action, - type, - targetBranch, - pipelineBranch, - releaseName, - tagName, - isReleaseOrTagFiltering - ), - '~commit:branch' - ); - }); - - it('determines to "~pr" when type is "pr"', () => { - type = 'pr'; - - assert.equal( - determineStartFrom( - action, - type, - targetBranch, - pipelineBranch, - releaseName, - tagName, - isReleaseOrTagFiltering - ), - '~pr' - ); - }); - - it('determines to "~pr:branch" when type is "pr" and targetBranch is branch', () => { - type = 'pr'; - targetBranch = 'branch'; - - assert.equal( - determineStartFrom( - action, - type, - targetBranch, - pipelineBranch, - releaseName, - tagName, - isReleaseOrTagFiltering - ), - '~pr:branch' - ); - }); - - it('determines to "~release" when action is "release"', () => { - action = 'release'; - isReleaseOrTagFiltering = false; - - assert.equal( - determineStartFrom( - action, - type, - targetBranch, - pipelineBranch, - releaseName, - tagName, - isReleaseOrTagFiltering - ), - '~release' - ); - }); - - it('determines to "~release" when action is "release" even targetBranch is branch', () => { - action = 'release'; - targetBranch = 'branch'; - isReleaseOrTagFiltering = false; - - assert.equal( - determineStartFrom( - action, - type, - targetBranch, - pipelineBranch, - releaseName, - tagName, - isReleaseOrTagFiltering - ), - '~release' - ); - }); - - it('determines to "~release:releaseName" when filter the release trigger', () => { - action = 'release'; - releaseName = 'releaseName'; - isReleaseOrTagFiltering = true; - - assert.equal( - determineStartFrom( - action, - type, - targetBranch, - pipelineBranch, - releaseName, - tagName, - isReleaseOrTagFiltering - ), - '~release:releaseName' - ); - }); - - it('determines to "~tag" when action is "tag"', () => { - action = 'tag'; - isReleaseOrTagFiltering = false; - - assert.equal( - determineStartFrom( - action, - type, - targetBranch, - pipelineBranch, - releaseName, - tagName, - isReleaseOrTagFiltering - ), - '~tag' - ); - }); - - it('determines to "~tag" when action is "tag" even targetBranch is branch', () => { - action = 'tag'; - targetBranch = 'branch'; - isReleaseOrTagFiltering = false; - - assert.equal( - determineStartFrom( - action, - type, - targetBranch, - pipelineBranch, - releaseName, - tagName, - isReleaseOrTagFiltering - ), - '~tag' - ); - }); - - it('determines to "~tag:tagName" when filter the tag trigger', () => { - action = 'tag'; - tagName = 'tagName'; - isReleaseOrTagFiltering = true; - - assert.equal( - determineStartFrom( - action, - type, - targetBranch, - pipelineBranch, - releaseName, - tagName, - isReleaseOrTagFiltering - ), - '~tag:tagName' - ); - }); -}); - describe('webhooks plugin test', () => { let jobFactoryMock; let buildFactoryMock; @@ -352,41 +148,6 @@ describe('webhooks plugin test', () => { ); }); - describe('resolveChainPR function', () => { - it('resolves ChainPR flag', () => { - // eslint-disable-next-line no-underscore-dangle - const resolveChainPR = RewiredWebhooks.__get__('resolveChainPR'); - - let chainPR; // undefined; - const pipeline = { - annotations: {} - }; - - pipeline.annotations[ANNOT_CHAIN_PR] = undefined; - assert.isFalse(resolveChainPR(chainPR, pipeline)); - pipeline.annotations[ANNOT_CHAIN_PR] = true; - assert.isTrue(resolveChainPR(chainPR, pipeline)); - pipeline.annotations[ANNOT_CHAIN_PR] = false; - assert.isFalse(resolveChainPR(chainPR, pipeline)); - - chainPR = true; - pipeline.annotations[ANNOT_CHAIN_PR] = undefined; - assert.isTrue(resolveChainPR(chainPR, pipeline)); - pipeline.annotations[ANNOT_CHAIN_PR] = true; - assert.isTrue(resolveChainPR(chainPR, pipeline)); - pipeline.annotations[ANNOT_CHAIN_PR] = false; - assert.isFalse(resolveChainPR(chainPR, pipeline)); - - chainPR = false; - pipeline.annotations[ANNOT_CHAIN_PR] = undefined; - assert.isFalse(resolveChainPR(chainPR, pipeline)); - pipeline.annotations[ANNOT_CHAIN_PR] = true; - assert.isTrue(resolveChainPR(chainPR, pipeline)); - pipeline.annotations[ANNOT_CHAIN_PR] = false; - assert.isFalse(resolveChainPR(chainPR, pipeline)); - }); - }); - describe('POST /webhooks', () => { const checkoutUrl = 'git@github.com:baxterthehacker/public-repo.git'; const fullCheckoutUrl = 'git@github.com:baxterthehacker/public-repo.git#master';