diff --git a/plugins/pipelines/create.js b/plugins/pipelines/create.js index 4e1e2529f..0f09ea132 100644 --- a/plugins/pipelines/create.js +++ b/plugins/pipelines/create.js @@ -113,10 +113,9 @@ module.exports = () => ({ }); } - const results = await Promise.all([ - pipeline.sync(), - pipeline.addWebhooks(`${request.server.info.uri}/v4/webhooks`) - ]); + const results = await pipeline.sync(); + + await pipeline.addWebhooks(`${request.server.info.uri}/v4/webhooks`); const location = urlLib.format({ host: request.headers.host, @@ -124,7 +123,7 @@ module.exports = () => ({ protocol: request.server.info.protocol, pathname: `${request.path}/${pipeline.id}` }); - const data = await results[0].toJson(); + const data = await results.toJson(); return h .response(data) diff --git a/plugins/pipelines/update.js b/plugins/pipelines/update.js index cf4106fc5..b181ed089 100644 --- a/plugins/pipelines/update.js +++ b/plugins/pipelines/update.js @@ -135,17 +135,15 @@ module.exports = () => ({ oldPipeline.name = scmRepo.name; // update pipeline with new scmRepo and branch - return oldPipeline - .update() - .then(updatedPipeline => - Promise.all([ - updatedPipeline.sync(), - updatedPipeline.addWebhooks( - `${request.server.info.uri}/v4/webhooks` - ) - ]) - ) - .then(results => h.response(results[0].toJson()).code(200)); + return oldPipeline.update().then(async updatedPipeline => { + await updatedPipeline.addWebhooks( + `${request.server.info.uri}/v4/webhooks` + ); + + const result = await updatedPipeline.sync(); + + return h.response(result.toJson()).code(200); + }); }) ) ); diff --git a/plugins/webhooks/index.js b/plugins/webhooks/index.js index e22434770..84f19cbf4 100644 --- a/plugins/webhooks/index.js +++ b/plugins/webhooks/index.js @@ -250,6 +250,20 @@ function getSkipMessageAndChainPR({ pipeline, prSource, restrictPR, chainPR }) { 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 @@ -278,9 +292,12 @@ async function triggeredPipelines( 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 = []; @@ -314,7 +331,9 @@ async function triggeredPipelines( ); }); - return pipelinesOnCommitBranch.concat(pipelinesOnOtherBranch); + const currentRepoPipelines = pipelinesOnCommitBranch.concat(pipelinesOnOtherBranch); + + return currentRepoPipelines.concat(pipelinesWithSubscribedRepos); } /** @@ -340,7 +359,6 @@ async function createPREvents(options, request) { const { username, scmConfig, - sha, prRef, prNum, pipelines, @@ -350,14 +368,17 @@ async function createPREvents(options, request) { action, prSource, restrictPR, - chainPR + chainPR, + ref, + releaseName, + meta } = options; const { scm } = request.server.app.pipelineFactory; - const { eventFactory } = request.server.app; - const { pipelineFactory } = request.server.app; + 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; @@ -366,16 +387,41 @@ async function createPREvents(options, request) { const b = await p.branch; // obtain pipeline's latest commit sha for branch specific job let configPipelineSha = ''; + let subscribedConfigSha = ''; + let eventConfig = {}; - try { - configPipelineSha = await pipelineFactory.scm.getCommitSha(scmConfig); - } catch (err) { - if (err.status >= 500) { - throw err; - } else { - logger.info(`skip create event for branch: ${b}`); + // 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, @@ -384,7 +430,9 @@ async function createPREvents(options, request) { chainPR }); - const eventConfig = { + const prInfo = await eventFactory.scm.getPrInfo(scmConfig); + + eventConfig = { pipelineId: p.id, type: 'pr', webhooks: true, @@ -399,19 +447,47 @@ async function createPREvents(options, request) { prRef, prNum, prTitle, - prInfo: await eventFactory.scm.getPrInfo(scmConfig), + prInfo, prSource, baseBranch: branch }; - if (skipMessage) { - eventConfig.skipMessage = skipMessage; - } - 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 mimiced 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); + } + + if (skipMessage) { + eventConfig.skipMessage = skipMessage; + } + return eventConfig; }) ); @@ -578,6 +654,41 @@ async function obtainScmToken(pluginOptions, userFactory, username, scmContext) 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 @@ -620,6 +731,7 @@ function pullRequestEvent(pluginOptions, request, h, parsed, token) { scmContext }; const { restrictPR, chainPR } = pluginOptions; + const meta = createMeta(parsed); request.log(['webhook', hookId], `PR #${prNum} ${action} for ${fullCheckoutUrl}`); @@ -659,7 +771,10 @@ function pullRequestEvent(pluginOptions, request, h, parsed, token) { fullCheckoutUrl, restrictPR, chainPR, - pipelines + pipelines, + ref, + releaseName, + meta }; await batchUpdateAdmins({ userFactory, pipelines, username, scmContext }); @@ -682,41 +797,6 @@ function pullRequestEvent(pluginOptions, request, h, parsed, token) { }); } -/** - * 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 {}; -} - /** * Create events for each pipeline * @async createEvents @@ -728,7 +808,15 @@ function createMeta(parsed) { * @param {String} [skipMessage] Message to skip starting builds * @returns {Promise} Promise that resolves into events */ -async function createEvents(eventFactory, userFactory, pipelineFactory, pipelines, parsed, skipMessage) { +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); @@ -820,6 +908,41 @@ async function createEvents(eventFactory, userFactory, pipelineFactory, pipeline 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; } @@ -855,9 +978,7 @@ async function createEvents(eventFactory, userFactory, pipelineFactory, pipeline * @param {String} [skipMessage] Message to skip starting builds */ async function pushEvent(request, h, parsed, skipMessage, token) { - const { eventFactory } = request.server.app; - const { pipelineFactory } = request.server.app; - const { userFactory } = request.server.app; + const { eventFactory, pipelineFactory, userFactory } = request.server.app; const { hookId, checkoutUrl, branch, scmContext, type, action, changedFiles, releaseName, ref } = parsed; const fullCheckoutUrl = `${checkoutUrl}#${branch}`; const scmConfig = { @@ -891,7 +1012,15 @@ async function pushEvent(request, h, parsed, skipMessage, token) { 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); + events = await createEvents( + eventFactory, + userFactory, + pipelineFactory, + pipelines, + parsed, + skipMessage, + scmConfig + ); } const hasBuildEvents = events.filter(e => e.builds !== null); diff --git a/test/plugins/pipelines.test.js b/test/plugins/pipelines.test.js index 46c0c3d96..dab931ec9 100644 --- a/test/plugins/pipelines.test.js +++ b/test/plugins/pipelines.test.js @@ -1824,7 +1824,7 @@ describe('pipeline plugin test', () => { it('returns 200 when the user is admin of old repo with deprecated scmContext', () => { pipelineMock.admins = { [username]: true }; - pipelineMock.scmContext = 'depreacated'; + pipelineMock.scmContext = 'deprecated'; return server.inject(options).then(reply => { // Only call once to get permissions on the new repo diff --git a/test/plugins/webhooks.test.js b/test/plugins/webhooks.test.js index f6a7a3a6e..cfd30461f 100644 --- a/test/plugins/webhooks.test.js +++ b/test/plugins/webhooks.test.js @@ -390,6 +390,7 @@ describe('webhooks plugin test', () => { const checkoutUrl = 'git@github.com:baxterthehacker/public-repo.git'; const fullCheckoutUrl = 'git@github.com:baxterthehacker/public-repo.git#master'; const scmUri = 'github.com:123456:master'; + const scmRepoId = `github.com:123456`; const pipelineId = 'pipelineHash'; const jobId = 2; const buildId = 'buildHash'; @@ -579,6 +580,9 @@ describe('webhooks plugin test', () => { pipelineMock.workflowGraph = workflowGraph; pipelineMock.jobs = Promise.resolve([mainJobMock, jobMock]); pipelineFactoryMock.scm.parseHook.withArgs(reqHeaders, payload).resolves(parsed); + pipelineFactoryMock.list + .withArgs({ search: { field: 'subscribedScmUrlsWithActions', keyword: `%${scmRepoId}:%` } }) + .resolves([pipelineMock]); pipelineFactoryMock.list.resolves([pipelineMock]); }); @@ -1139,6 +1143,9 @@ describe('webhooks plugin test', () => { }); pipelineFactoryMock.list.resolves([pipelineMock, pMock1, pMock2, pMock3]); + pipelineFactoryMock.list + .withArgs({ search: { field: 'subscribedScmUrlsWithActions', keyword: `%${scmRepoId}:%` } }) + .resolves([]); return server.inject(options).then(reply => { assert.equal(reply.statusCode, 201); @@ -1236,6 +1243,9 @@ describe('webhooks plugin test', () => { pipelineFactoryMock.scm.getChangedFiles.resolves(['lib/test.js']); pipelineFactoryMock.list.resolves([pipelineMock, pMock1, pMock2]); + pipelineFactoryMock.list + .withArgs({ search: { field: 'subscribedScmUrlsWithActions', keyword: `%${scmRepoId}:%` } }) + .resolves([]); return server.inject(options).then(reply => { assert.equal(reply.statusCode, 201); @@ -1328,6 +1338,38 @@ describe('webhooks plugin test', () => { }); }); + it('returns 201 when the hook source triggers subscribed event', () => { + pipelineFactoryMock.scm.parseUrl + .withArgs({ checkoutUrl: fullCheckoutUrl, token, scmContext }) + .resolves('github.com:789123:master'); + pipelineFactoryMock.list.resolves([]); + pipelineFactoryMock.list + .withArgs({ search: { field: 'subscribedScmUrlsWithActions', keyword: '%github.com:789123:%' } }) + .resolves([pipelineMock]); + + return server.inject(options).then(reply => { + assert.equal(reply.statusCode, 201); + assert.calledWith(eventFactoryMock.create, { + pipelineId, + type: 'pipeline', + webhooks: true, + username, + scmContext, + sha: latestSha, + configPipelineSha: latestSha, + subscribedConfigSha: sha, + startFrom: '~subscribe', + baseBranch: 'master', + causeMessage: `Merged by ${username}`, + changedFiles, + releaseName: undefined, + ref: undefined, + meta: {}, + subscribedEvent: true + }); + }); + }); + it('returns 204 when no pipeline', () => { pipelineFactoryMock.get.resolves(null); pipelineFactoryMock.list.resolves([]); @@ -1812,6 +1854,48 @@ describe('webhooks plugin test', () => { }); }); + it('returns 201 when the hook source triggers subscribed event', () => { + pipelineFactoryMock.scm.parseUrl + .withArgs({ checkoutUrl: fullCheckoutUrl, token, scmContext }) + .resolves('github.com:789123:master'); + pipelineFactoryMock.list.resolves([]); + pipelineMock.baxterthehacker = 'master'; + pipelineMock.admins = { + baxterthehacker: true + }; + pipelineFactoryMock.list + .withArgs({ + search: { field: 'subscribedScmUrlsWithActions', keyword: '%github.com:789123:%' } + }) + .resolves([pipelineMock]); + eventFactoryMock.scm.getPrInfo.resolves({ + url: 'foo' + }); + + return server.inject(options).then(reply => { + assert.equal(reply.statusCode, 201); + assert.calledWith(eventFactoryMock.create, { + pipelineId, + type: 'pipeline', + webhooks: true, + username, + scmContext, + sha: latestSha, + configPipelineSha: latestSha, + subscribedConfigSha: sha, + startFrom: '~subscribe', + baseBranch: 'master', + causeMessage: `Merged by ${username}`, + changedFiles, + releaseName: undefined, + ref: undefined, + meta: {}, + subscribedEvent: true, + subscribedSourceUrl: 'foo' + }); + }); + }); + it('returns 201 when getCommitSha() is rejected', () => { pipelineFactoryMock.scm.getCommitSha.rejects(new Error('some error')); @@ -2217,6 +2301,10 @@ describe('webhooks plugin test', () => { it('has the workflow for stopping builds before starting a new one', () => { const abortMsg = 'Aborted because new commit was pushed to PR#1'; + pipelineFactoryMock.list + .withArgs({ search: { field: 'subscribedScmUrlsWithActions', keyword: `%${scmRepoId}:%` } }) + .resolves([]); + return server.inject(options).then(reply => { assert.calledOnce(model1.update); assert.calledOnce(model2.update); @@ -2448,13 +2536,18 @@ describe('webhooks plugin test', () => { pipelineFactoryMock.scm.parseHook.withArgs(reqHeaders, options.payload).resolves(parsed); }); - it('returns 200 on success', () => - server.inject(options).then(reply => { + it('returns 200 on success', () => { + pipelineFactoryMock.list + .withArgs({ search: { field: 'subscribedScmUrlsWithActions', keyword: `%${scmRepoId}:%` } }) + .resolves([]); + + return server.inject(options).then(reply => { assert.equal(reply.statusCode, 200); assert.calledOnce(jobMock.update); assert.strictEqual(jobMock.state, 'ENABLED'); assert.isTrue(jobMock.archived); - })); + }); + }); it('returns 204 when pipeline to be closed does not exist', () => { pipelineFactoryMock.list.resolves([]); @@ -2464,18 +2557,26 @@ describe('webhooks plugin test', () => { }); }); - it('stops running builds', () => - server.inject(options).then(() => { + it('stops running builds', () => { + pipelineFactoryMock.list + .withArgs({ search: { field: 'subscribedScmUrlsWithActions', keyword: `%${scmRepoId}:%` } }) + .resolves([]); + + return server.inject(options).then(() => { assert.calledOnce(model1.update); assert.calledOnce(model2.update); assert.strictEqual(model1.status, 'ABORTED'); assert.strictEqual(model1.statusMessage, 'Aborted because PR#1 was closed'); assert.strictEqual(model2.status, 'ABORTED'); assert.strictEqual(model2.statusMessage, 'Aborted because PR#1 was closed'); - })); + }); + }); it('returns 500 when failed', () => { jobMock.update.rejects(new Error('Failed to update')); + pipelineFactoryMock.list + .withArgs({ search: { field: 'subscribedScmUrlsWithActions', keyword: `%${scmRepoId}:%` } }) + .resolves([]); return server.inject(options).then(reply => { assert.equal(reply.statusCode, 500);