screwdriver-api 4.1.181 → 4.1.185

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1192 @@
1
+ 'use strict';
2
+
3
+ const workflowParser = require('screwdriver-workflow-parser');
4
+ const schema = require('screwdriver-data-schema');
5
+ const logger = require('screwdriver-logger');
6
+ const { getReadOnlyInfo } = require('../helper');
7
+
8
+ const ANNOT_NS = 'screwdriver.cd';
9
+ const ANNOT_CHAIN_PR = `${ANNOT_NS}/chainPR`;
10
+ const ANNOT_RESTRICT_PR = `${ANNOT_NS}/restrictPR`;
11
+ const EXTRA_TRIGGERS = schema.config.regex.EXTRA_TRIGGER;
12
+ const CHECKOUT_URL_SCHEMA = schema.config.regex.CHECKOUT_URL;
13
+ const CHECKOUT_URL_SCHEMA_REGEXP = new RegExp(CHECKOUT_URL_SCHEMA);
14
+
15
+ /**
16
+ * Check if tag or release filtering is enabled or not
17
+ * @param {String} action SCM webhook action type
18
+ * @param {Array} workflowGraph pipeline workflowGraph
19
+ * @returns {Boolean} isFilteringEnabled
20
+ */
21
+ function isReleaseOrTagFilteringEnabled(action, workflowGraph) {
22
+ let isFilteringEnabled = true;
23
+
24
+ workflowGraph.edges.forEach(edge => {
25
+ const releaseOrTagRegExp = action === 'release' ? new RegExp('^~(release)$') : new RegExp('^~(tag)$');
26
+
27
+ if (edge.src.match(releaseOrTagRegExp)) {
28
+ isFilteringEnabled = false;
29
+ }
30
+ });
31
+
32
+ return isFilteringEnabled;
33
+ }
34
+ /**
35
+ * Determine "startFrom" with type, action and branches
36
+ * @param {String} action SCM webhook action type
37
+ * @param {String} type Triggered SCM event type ('pr' or 'repo')
38
+ * @param {String} targetBranch The branch against which commit is pushed
39
+ * @param {String} pipelineBranch The pipeline branch
40
+ * @param {String} releaseName SCM webhook release name
41
+ * @param {String} tagName SCM webhook tag name
42
+ * @param {Boolean} isReleaseOrTagFiltering If the tag or release filtering is enabled
43
+ * @returns {String} startFrom
44
+ */
45
+ function determineStartFrom(action, type, targetBranch, pipelineBranch, releaseName, tagName, isReleaseOrTagFiltering) {
46
+ let startFrom;
47
+
48
+ if (type && type === 'pr') {
49
+ startFrom = '~pr';
50
+ } else {
51
+ switch (action) {
52
+ case 'release':
53
+ return releaseName && isReleaseOrTagFiltering ? `~release:${releaseName}` : '~release';
54
+ case 'tag':
55
+ if (!tagName) {
56
+ logger.error('The ref of SCM Webhook is missing.');
57
+
58
+ return '';
59
+ }
60
+
61
+ return isReleaseOrTagFiltering ? `~tag:${tagName}` : '~tag';
62
+ default:
63
+ startFrom = '~commit';
64
+ break;
65
+ }
66
+ }
67
+
68
+ return targetBranch !== pipelineBranch ? `${startFrom}:${targetBranch}` : startFrom;
69
+ }
70
+
71
+ /**
72
+ * Update admins array
73
+ * @param {UserFactory} userFactory UserFactory object
74
+ * @param {String} username Username of user
75
+ * @param {String} scmContext Scm which pipeline's repository exists in
76
+ * @param {Pipeline} pipeline Pipeline object
77
+ * @param {PipelineFactory}pipelineFactory PipelineFactory object
78
+ * @return {Promise} Updates the pipeline admins and throws an error if not an admin
79
+ */
80
+ async function updateAdmins(userFactory, username, scmContext, pipeline, pipelineFactory) {
81
+ const { readOnlyEnabled } = getReadOnlyInfo({ scm: pipelineFactory.scm, scmContext });
82
+
83
+ // Skip update admins if read-only pipeline
84
+ if (readOnlyEnabled) {
85
+ return Promise.resolve();
86
+ }
87
+
88
+ try {
89
+ const user = await userFactory.get({ username, scmContext });
90
+ const userPermissions = await user.getPermissions(pipeline.scmUri);
91
+ const newAdmins = pipeline.admins;
92
+
93
+ // Delete user from admin list if bad permissions
94
+ if (!userPermissions.push) {
95
+ delete newAdmins[username];
96
+ // This is needed to make admins dirty and update db
97
+ pipeline.admins = newAdmins;
98
+
99
+ return pipeline.update();
100
+ }
101
+ // Add user as admin if permissions good and does not already exist
102
+ if (!pipeline.admins[username]) {
103
+ newAdmins[username] = true;
104
+ // This is needed to make admins dirty and update db
105
+ pipeline.admins = newAdmins;
106
+
107
+ return pipeline.update();
108
+ }
109
+ } catch (err) {
110
+ logger.info(err.message);
111
+ }
112
+
113
+ return Promise.resolve();
114
+ }
115
+
116
+ /**
117
+ * Update admins for an array of pipelines
118
+ * @param {Object} config.userFactory UserFactory
119
+ * @param {Array} config.pipelines An array of pipelines
120
+ * @param {String} config.username Username
121
+ * @param {String} config.scmContext ScmContext
122
+ * @param {PipelineFactory} config.pipelineFactory PipelineFactory object
123
+ * @return {Promise}
124
+ */
125
+ async function batchUpdateAdmins({ userFactory, pipelines, username, scmContext, pipelineFactory }) {
126
+ await Promise.all(
127
+ pipelines.map(pipeline => updateAdmins(userFactory, username, scmContext, pipeline, pipelineFactory))
128
+ );
129
+ }
130
+
131
+ /**
132
+ * Check if the PR is being restricted or not
133
+ * @method isRestrictedPR
134
+ * @param {String} restriction Is the pipeline restricting PR based on origin
135
+ * @param {String} prSource Origin of the PR
136
+ * @return {Boolean} Should the build be restricted
137
+ */
138
+ function isRestrictedPR(restriction, prSource) {
139
+ switch (restriction) {
140
+ case 'all':
141
+ return true;
142
+ case 'branch':
143
+ case 'fork':
144
+ return prSource === restriction;
145
+ case 'none':
146
+ default:
147
+ return false;
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Stop a job by stopping all the builds associated with it
153
+ * If the build is running, set state to ABORTED
154
+ * @method stopJob
155
+ * @param {Object} config
156
+ * @param {String} config.action Event action ('Closed' or 'Synchronized')
157
+ * @param {Job} config.job Job to stop
158
+ * @param {String} config.prNum Pull request number
159
+ * @return {Promise}
160
+ */
161
+ function stopJob({ job, prNum, action }) {
162
+ const stopRunningBuild = build => {
163
+ if (build.isDone()) {
164
+ return Promise.resolve();
165
+ }
166
+
167
+ const statusMessage =
168
+ action === 'Closed'
169
+ ? `Aborted because PR#${prNum} was closed`
170
+ : `Aborted because new commit was pushed to PR#${prNum}`;
171
+
172
+ build.status = 'ABORTED';
173
+ build.statusMessage = statusMessage;
174
+
175
+ return build.update();
176
+ };
177
+
178
+ return (
179
+ job
180
+ .getRunningBuilds()
181
+ // Stop running builds
182
+ .then(builds => Promise.all(builds.map(stopRunningBuild)))
183
+ );
184
+ }
185
+
186
+ /**
187
+ * Check if the pipeline has a triggered job or not
188
+ * @method hasTriggeredJob
189
+ * @param {Pipeline} pipeline The pipeline to check
190
+ * @param {String} startFrom The trigger name
191
+ * @returns {Boolean} True if the pipeline contains the triggered job
192
+ */
193
+ function hasTriggeredJob(pipeline, startFrom) {
194
+ try {
195
+ const nextJobs = workflowParser.getNextJobs(pipeline.workflowGraph, {
196
+ trigger: startFrom
197
+ });
198
+
199
+ return nextJobs.length > 0;
200
+ } catch (err) {
201
+ logger.error(`Error finding triggered jobs for ${pipeline.id}: ${err}`);
202
+
203
+ return false;
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Check if changedFiles are under rootDir. If no custom rootDir, return true.
209
+ * @param {Object} pipeline
210
+ * @param {Array} changedFiles
211
+ * @return {Boolean}
212
+ */
213
+ function hasChangesUnderRootDir(pipeline, changedFiles) {
214
+ const splitUri = pipeline.scmUri.split(':');
215
+ const rootDir = splitUri.length > 3 ? splitUri[3] : '';
216
+ const changes = changedFiles || [];
217
+
218
+ // Only check if rootDir is set
219
+ if (rootDir) {
220
+ return changes.some(file => file.startsWith(rootDir));
221
+ }
222
+
223
+ return true;
224
+ }
225
+
226
+ /**
227
+ * Resolve ChainPR flag
228
+ * @method resolveChainPR
229
+ * @param {Boolean} chainPR Plugin Chain PR flag
230
+ * @param {Pipeline} pipeline Pipeline
231
+ * @param {Object} pipeline.annotations Pipeline-level annotations
232
+ * @return {Boolean}
233
+ */
234
+ function resolveChainPR(chainPR, pipeline) {
235
+ const defaultChainPR = typeof chainPR === 'undefined' ? false : chainPR;
236
+ const annotChainPR = pipeline.annotations[ANNOT_CHAIN_PR];
237
+
238
+ return typeof annotChainPR === 'undefined' ? defaultChainPR : annotChainPR;
239
+ }
240
+
241
+ /**
242
+ * Returns an object with resolvedChainPR and skipMessage
243
+ * @param {Object} config.pipeline Pipeline
244
+ * @param {String} config.prSource The origin of this PR
245
+ * @param {String} config.restrictPR Restrict PR setting
246
+ * @param {Boolean} config.chainPR Chain PR flag
247
+ * @return {Object}
248
+ */
249
+ function getSkipMessageAndChainPR({ pipeline, prSource, restrictPR, chainPR }) {
250
+ const defaultRestrictPR = restrictPR || 'none';
251
+ const restriction = pipeline.annotations[ANNOT_RESTRICT_PR] || defaultRestrictPR;
252
+ const result = {
253
+ resolvedChainPR: resolveChainPR(chainPR, pipeline)
254
+ };
255
+
256
+ // Check for restriction upfront
257
+ if (isRestrictedPR(restriction, prSource)) {
258
+ result.skipMessage = `Skipping build since pipeline is configured to restrict ${restriction} and PR is ${prSource}`;
259
+ }
260
+
261
+ return result;
262
+ }
263
+
264
+ /**
265
+ * Returns the uri keeping only the host and the repo ID
266
+ * @method uriTrimmer
267
+ * @param {String} uri The uri to be trimmed
268
+ * @return {String}
269
+ */
270
+ const uriTrimmer = uri => {
271
+ const uriToArray = uri.split(':');
272
+
273
+ while (uriToArray.length > 2) uriToArray.pop();
274
+
275
+ return uriToArray.join(':');
276
+ };
277
+
278
+ /**
279
+ * Get all pipelines which has triggered job
280
+ * @method triggeredPipelines
281
+ * @param {PipelineFactory} pipelineFactory The pipeline factory to get the branch list from
282
+ * @param {Object} scmConfig Has the token and scmUri to get branches
283
+ * @param {String} branch The branch which is committed
284
+ * @param {String} type Triggered GitHub event type ('pr' or 'repo')
285
+ * @param {String} action Triggered GitHub event action
286
+ * @param {Array} changedFiles Changed files in this commit
287
+ * @param {String} releaseName SCM webhook release name
288
+ * @param {String} tagName SCM webhook tag name
289
+ * @returns {Promise} Promise that resolves into triggered pipelines
290
+ */
291
+ async function triggeredPipelines(
292
+ pipelineFactory,
293
+ scmConfig,
294
+ branch,
295
+ type,
296
+ action,
297
+ changedFiles,
298
+ releaseName,
299
+ tagName
300
+ ) {
301
+ const { scmUri } = scmConfig;
302
+ const splitUri = scmUri.split(':');
303
+ const scmBranch = `${splitUri[0]}:${splitUri[1]}:${splitUri[2]}`;
304
+ const scmRepoId = `${splitUri[0]}:${splitUri[1]}`;
305
+ const listConfig = { search: { field: 'scmUri', keyword: `${scmRepoId}:%` } };
306
+ const externalRepoSearchConfig = { search: { field: 'subscribedScmUrlsWithActions', keyword: `%${scmRepoId}:%` } };
307
+
308
+ const pipelines = await pipelineFactory.list(listConfig);
309
+
310
+ const pipelinesWithSubscribedRepos = await pipelineFactory.list(externalRepoSearchConfig);
311
+
312
+ let pipelinesOnCommitBranch = [];
313
+ let pipelinesOnOtherBranch = [];
314
+
315
+ pipelines.forEach(p => {
316
+ // This uri expects 'scmUriDomain:repoId:branchName:rootDir'. To Compare, rootDir is ignored.
317
+ const splitScmUri = p.scmUri.split(':');
318
+ const pipelineScmBranch = `${splitScmUri[0]}:${splitScmUri[1]}:${splitScmUri[2]}`;
319
+
320
+ if (pipelineScmBranch === scmBranch) {
321
+ pipelinesOnCommitBranch.push(p);
322
+ } else {
323
+ pipelinesOnOtherBranch.push(p);
324
+ }
325
+ });
326
+
327
+ // Build runs regardless of changedFiles when release/tag trigger
328
+ pipelinesOnCommitBranch = pipelinesOnCommitBranch.filter(
329
+ p => ['release', 'tag'].includes(action) || hasChangesUnderRootDir(p, changedFiles)
330
+ );
331
+
332
+ pipelinesOnOtherBranch = pipelinesOnOtherBranch.filter(p => {
333
+ let isReleaseOrTagFiltering = '';
334
+
335
+ if (action === 'release' || action === 'tag') {
336
+ isReleaseOrTagFiltering = isReleaseOrTagFilteringEnabled(action, p.workflowGraph);
337
+ }
338
+
339
+ return hasTriggeredJob(
340
+ p,
341
+ determineStartFrom(action, type, branch, null, releaseName, tagName, isReleaseOrTagFiltering)
342
+ );
343
+ });
344
+
345
+ const currentRepoPipelines = pipelinesOnCommitBranch.concat(pipelinesOnOtherBranch);
346
+
347
+ return currentRepoPipelines.concat(pipelinesWithSubscribedRepos);
348
+ }
349
+
350
+ /**
351
+ * Create events for each pipeline
352
+ * @async createPREvents
353
+ * @param {Object} options
354
+ * @param {String} options.username User who created the PR
355
+ * @param {String} options.scmConfig Has the token and scmUri to get branches
356
+ * @param {String} options.sha Specific SHA1 commit to start the build with
357
+ * @param {String} options.prRef Reference to pull request
358
+ * @param {String} options.prNum Pull request number
359
+ * @param {String} options.prTitle Pull request title
360
+ * @param {Array} options.changedFiles List of changed files
361
+ * @param {String} options.branch The branch against which pr is opened
362
+ * @param {String} options.action Event action
363
+ * @param {String} options.prSource The origin of this PR
364
+ * @param {String} options.restrictPR Restrict PR setting
365
+ * @param {Boolean} options.chainPR Chain PR flag
366
+ * @param {Hapi.request} request Request from user
367
+ * @return {Promise}
368
+ */
369
+ async function createPREvents(options, request) {
370
+ const {
371
+ username,
372
+ scmConfig,
373
+ prRef,
374
+ prNum,
375
+ pipelines,
376
+ prTitle,
377
+ changedFiles,
378
+ branch,
379
+ action,
380
+ prSource,
381
+ restrictPR,
382
+ chainPR,
383
+ ref,
384
+ releaseName,
385
+ meta
386
+ } = options;
387
+ const { scm } = request.server.app.pipelineFactory;
388
+ const { eventFactory, pipelineFactory, userFactory } = request.server.app;
389
+ const scmDisplayName = scm.getDisplayName({ scmContext: scmConfig.scmContext });
390
+ const userDisplayName = `${scmDisplayName}:${username}`;
391
+ const events = [];
392
+ let { sha } = options;
393
+
394
+ scmConfig.prNum = prNum;
395
+
396
+ const eventConfigs = await Promise.all(
397
+ pipelines.map(async p => {
398
+ try {
399
+ const b = await p.branch;
400
+ // obtain pipeline's latest commit sha for branch specific job
401
+ let configPipelineSha = '';
402
+ let subscribedConfigSha = '';
403
+ let eventConfig = {};
404
+
405
+ // Check if the webhook event is from a subscribed repo and
406
+ // and fetch the source repo commit sha and save the subscribed sha
407
+ if (uriTrimmer(scmConfig.scmUri) !== uriTrimmer(p.scmUri)) {
408
+ subscribedConfigSha = sha;
409
+
410
+ try {
411
+ sha = await pipelineFactory.scm.getCommitSha({
412
+ scmUri: p.scmUri,
413
+ scmContext: scmConfig.scmContext,
414
+ token: scmConfig.token
415
+ });
416
+ } catch (err) {
417
+ if (err.status >= 500) {
418
+ throw err;
419
+ } else {
420
+ logger.info(`skip create event for branch: ${b}`);
421
+ }
422
+ }
423
+
424
+ configPipelineSha = sha;
425
+ } else {
426
+ try {
427
+ configPipelineSha = await pipelineFactory.scm.getCommitSha(scmConfig);
428
+ } catch (err) {
429
+ if (err.status >= 500) {
430
+ throw err;
431
+ } else {
432
+ logger.info(`skip create event for branch: ${b}`);
433
+ }
434
+ }
435
+ }
436
+
437
+ const { skipMessage, resolvedChainPR } = getSkipMessageAndChainPR({
438
+ // Workaround for pipelines which has NULL value in `pipeline.annotations`
439
+ pipeline: !p.annotations ? { annotations: {}, ...p } : p,
440
+ prSource,
441
+ restrictPR,
442
+ chainPR
443
+ });
444
+
445
+ const prInfo = await eventFactory.scm.getPrInfo(scmConfig);
446
+
447
+ eventConfig = {
448
+ pipelineId: p.id,
449
+ type: 'pr',
450
+ webhooks: true,
451
+ username,
452
+ scmContext: scmConfig.scmContext,
453
+ sha,
454
+ configPipelineSha,
455
+ startFrom: `~pr:${branch}`,
456
+ changedFiles,
457
+ causeMessage: `${action} by ${userDisplayName}`,
458
+ chainPR: resolvedChainPR,
459
+ prRef,
460
+ prNum,
461
+ prTitle,
462
+ prInfo,
463
+ prSource,
464
+ baseBranch: branch
465
+ };
466
+
467
+ if (b === branch) {
468
+ eventConfig.startFrom = '~pr';
469
+ }
470
+
471
+ // Check if the webhook event is from a subscribed repo and
472
+ // set the jobs entrypoint from ~startFrom
473
+ // For subscribed PR event, it should be mimicked as a commit
474
+ // in order to function properly
475
+ if (uriTrimmer(scmConfig.scmUri) !== uriTrimmer(p.scmUri)) {
476
+ eventConfig = {
477
+ pipelineId: p.id,
478
+ type: 'pipeline',
479
+ webhooks: true,
480
+ username,
481
+ scmContext: scmConfig.scmContext,
482
+ startFrom: '~subscribe',
483
+ sha,
484
+ configPipelineSha,
485
+ changedFiles,
486
+ baseBranch: branch,
487
+ causeMessage: `Merged by ${username}`,
488
+ meta,
489
+ releaseName,
490
+ ref,
491
+ subscribedEvent: true,
492
+ subscribedConfigSha,
493
+ subscribedSourceUrl: prInfo.url
494
+ };
495
+
496
+ await updateAdmins(userFactory, username, scmConfig.scmContext, p.id, pipelineFactory);
497
+ }
498
+
499
+ if (skipMessage) {
500
+ eventConfig.skipMessage = skipMessage;
501
+ }
502
+
503
+ return eventConfig;
504
+ } catch (err) {
505
+ logger.warn(`pipeline:${p.id} error in starting event`, err);
506
+
507
+ return null;
508
+ }
509
+ })
510
+ );
511
+
512
+ eventConfigs.forEach(eventConfig => {
513
+ if (eventConfig && eventConfig.configPipelineSha) {
514
+ events.push(eventFactory.create(eventConfig));
515
+ }
516
+ });
517
+
518
+ return Promise.all(events);
519
+ }
520
+
521
+ /**
522
+ * Stop all the relevant PR jobs for an array of pipelines
523
+ * @async batchStopJobs
524
+ * @param {Array} config.pipelines An array of pipeline
525
+ * @param {Integer} config.prNum PR number
526
+ * @param {String} config.action Event action
527
+ * @param {String} config.name Prefix of the PR job name: PR-prNum
528
+ */
529
+ async function batchStopJobs({ pipelines, prNum, action, name }) {
530
+ const prJobs = await Promise.all(
531
+ pipelines.map(p => p.getJobs({ type: 'pr' }).then(jobs => jobs.filter(j => j.name.includes(name))))
532
+ );
533
+ const flatPRJobs = prJobs.reduce((prev, curr) => prev.concat(curr));
534
+
535
+ await Promise.all(flatPRJobs.map(j => stopJob({ job: j, prNum, action })));
536
+ }
537
+
538
+ /**
539
+ * Create a new job and start the build for an opened pull-request
540
+ * @async pullRequestOpened
541
+ * @param {Object} options
542
+ * @param {String} options.hookId Unique ID for this scm event
543
+ * @param {String} options.prSource The origin of this PR
544
+ * @param {Pipeline} options.pipeline Pipeline model for the pr
545
+ * @param {String} options.restrictPR Restrict PR setting
546
+ * @param {Boolean} options.chainPR Chain PR flag
547
+ * @param {Hapi.request} request Request from user
548
+ * @param {Hapi.h} h Response toolkit
549
+ */
550
+ async function pullRequestOpened(options, request, h) {
551
+ const { hookId } = options;
552
+
553
+ return createPREvents(options, request)
554
+ .then(events => {
555
+ events.forEach(e => {
556
+ request.log(['webhook', hookId, e.id], `Event ${e.id} started`);
557
+ });
558
+
559
+ return h.response().code(201);
560
+ })
561
+ .catch(err => {
562
+ logger.error(
563
+ `Failed to pullRequestOpened: [${hookId}, pipeline:${options.pipeline && options.pipeline.id}]: ${err}`
564
+ );
565
+
566
+ throw err;
567
+ });
568
+ }
569
+
570
+ /**
571
+ * Stop any running builds and disable the job for closed pull-request
572
+ * @async pullRequestClosed
573
+ * @param {Object} options
574
+ * @param {String} options.hookId Unique ID for this scm event
575
+ * @param {Pipeline} options.pipeline Pipeline model for the pr
576
+ * @param {String} options.name Name of the PR: PR-prNum
577
+ * @param {String} options.prNum Pull request number
578
+ * @param {String} options.action Event action
579
+ * @param {String} options.fullCheckoutUrl CheckoutUrl with branch name
580
+ * @param {Hapi.request} request Request from user
581
+ * @param {Hapi.reply} reply Reply to user
582
+ */
583
+ async function pullRequestClosed(options, request, h) {
584
+ const { pipelines, hookId, name, prNum, action } = options;
585
+ const updatePRJobs = job =>
586
+ stopJob({ job, prNum, action })
587
+ .then(() => request.log(['webhook', hookId, job.id], `${job.name} stopped`))
588
+ .then(() => {
589
+ job.archived = true;
590
+
591
+ return job.update();
592
+ })
593
+ .then(() => request.log(['webhook', hookId, job.id], `${job.name} disabled and archived`));
594
+
595
+ return Promise.all(
596
+ pipelines.map(p =>
597
+ p.getJobs({ type: 'pr' }).then(jobs => {
598
+ const prJobs = jobs.filter(j => j.name.includes(name));
599
+
600
+ return Promise.all(prJobs.map(j => updatePRJobs(j)));
601
+ })
602
+ )
603
+ )
604
+ .then(() => h.response().code(200))
605
+ .catch(err => {
606
+ logger.error(
607
+ `Failed to pullRequestClosed: [${hookId}, pipeline:${options.pipeline && options.pipeline.id}]: ${err}`
608
+ );
609
+
610
+ throw err;
611
+ });
612
+ }
613
+
614
+ /**
615
+ * Stop any running builds and start the build for the synchronized pull-request
616
+ * @async pullRequestSync
617
+ * @param {Object} options
618
+ * @param {String} options.hookId Unique ID for this scm event
619
+ * @param {String} options.name Name of the new job (PR-1)
620
+ * @param {String} options.prSource The origin of this PR
621
+ * @param {String} options.restrictPR Restrict PR setting
622
+ * @param {Boolean} options.chainPR Chain PR flag
623
+ * @param {Pipeline} options.pipeline Pipeline model for the pr
624
+ * @param {Array} options.changedFiles List of files that were changed
625
+ * @param {String} options.prNum Pull request number
626
+ * @param {String} options.action Event action
627
+ * @param {Hapi.request} request Request from user
628
+ * @param {Hapi.reply} reply Reply to user
629
+ */
630
+ async function pullRequestSync(options, request, h) {
631
+ const { pipelines, hookId, name, prNum, action } = options;
632
+
633
+ await batchStopJobs({ pipelines, name, prNum, action });
634
+
635
+ request.log(['webhook', hookId], `Job(s) for ${name} stopped`);
636
+
637
+ return createPREvents(options, request)
638
+ .then(events => {
639
+ events.forEach(e => {
640
+ request.log(['webhook', hookId, e.id], `Event ${e.id} started`);
641
+ });
642
+
643
+ return h.response().code(201);
644
+ })
645
+ .catch(err => {
646
+ logger.error(
647
+ `Failed to pullRequestSync: [${hookId}, pipeline:${options.pipeline && options.pipeline.id}]: ${err}`
648
+ );
649
+
650
+ throw err;
651
+ });
652
+ }
653
+
654
+ /**
655
+ * Obtains the SCM token for a given user.
656
+ * If a user does not have a valid SCM token registered with Screwdriver,
657
+ * it will use a generic user's token instead.
658
+ * If pipeline is in read-only SCM, use read-only token.
659
+ * Some SCM services have different thresholds between IP requests and token requests. This is
660
+ * to ensure we have a token to access the SCM service without being restricted by these quotas
661
+ * @method obtainScmToken
662
+ * @param {Object} pluginOptions
663
+ * @param {String} pluginOptions.username Generic scm username
664
+ * @param {UserFactory} userFactory UserFactory object
665
+ * @param {String} username Name of the user that the SCM token is associated with
666
+ * @param {String} scmContext Scm which pipeline's repository exists in
667
+ * @param {Object} scm Scm
668
+ * @return {Promise} Promise that resolves into a SCM token
669
+ */
670
+ async function obtainScmToken({ pluginOptions, userFactory, username, scmContext, scm }) {
671
+ const { readOnlyEnabled, headlessAccessToken } = getReadOnlyInfo({ scm, scmContext });
672
+
673
+ // If pipeline is in read-only SCM, use read-only token
674
+ if (readOnlyEnabled && headlessAccessToken) {
675
+ return headlessAccessToken;
676
+ }
677
+
678
+ const user = await userFactory.get({ username, scmContext });
679
+
680
+ // Use generic username and token
681
+ if (!user) {
682
+ const genericUsername = pluginOptions.username;
683
+ const buildBotUser = await userFactory.get({ username: genericUsername, scmContext });
684
+
685
+ return buildBotUser.unsealToken();
686
+ }
687
+
688
+ return user.unsealToken();
689
+ }
690
+
691
+ /**
692
+ * Create metadata by the parsed event
693
+ * @param {Object} parsed It has information to create metadata
694
+ * @returns {Object} Metadata
695
+ */
696
+ function createMeta(parsed) {
697
+ const { action, ref, releaseId, releaseName, releaseAuthor } = parsed;
698
+
699
+ if (action === 'release') {
700
+ return {
701
+ sd: {
702
+ release: {
703
+ id: releaseId,
704
+ name: releaseName,
705
+ author: releaseAuthor
706
+ },
707
+ tag: {
708
+ name: ref
709
+ }
710
+ }
711
+ };
712
+ }
713
+ if (action === 'tag') {
714
+ return {
715
+ sd: {
716
+ tag: {
717
+ name: ref
718
+ }
719
+ }
720
+ };
721
+ }
722
+
723
+ return {};
724
+ }
725
+
726
+ /**
727
+ * Act on a Pull Request change (create, sync, close)
728
+ * - Opening a PR should sync the pipeline (creating the job) and start the new PR job
729
+ * - Syncing a PR should stop the existing PR job and start a new one
730
+ * - Closing a PR should stop the PR job and sync the pipeline (disabling the job)
731
+ * @method pullRequestEvent
732
+ * @param {Object} pluginOptions
733
+ * @param {String} pluginOptions.username Generic scm username
734
+ * @param {String} pluginOptions.restrictPR Restrict PR setting
735
+ * @param {Boolean} pluginOptions.chainPR Chain PR flag
736
+ * @param {Hapi.request} request Request from user
737
+ * @param {Hapi.reply} reply Reply to user
738
+ * @param {String} token The token used to authenticate to the SCM
739
+ * @param {Object} parsed
740
+ */
741
+ function pullRequestEvent(pluginOptions, request, h, parsed, token) {
742
+ const { pipelineFactory, userFactory } = request.server.app;
743
+ const {
744
+ hookId,
745
+ action,
746
+ checkoutUrl,
747
+ branch,
748
+ sha,
749
+ prNum,
750
+ prTitle,
751
+ prRef,
752
+ prSource,
753
+ username,
754
+ scmContext,
755
+ changedFiles,
756
+ type,
757
+ releaseName,
758
+ ref
759
+ } = parsed;
760
+ const fullCheckoutUrl = `${checkoutUrl}#${branch}`;
761
+ const scmConfig = {
762
+ scmUri: '',
763
+ token,
764
+ scmContext
765
+ };
766
+ const { restrictPR, chainPR } = pluginOptions;
767
+ const meta = createMeta(parsed);
768
+
769
+ request.log(['webhook', hookId], `PR #${prNum} ${action} for ${fullCheckoutUrl}`);
770
+
771
+ return pipelineFactory.scm
772
+ .parseUrl({
773
+ checkoutUrl: fullCheckoutUrl,
774
+ token,
775
+ scmContext
776
+ })
777
+ .then(scmUri => {
778
+ scmConfig.scmUri = scmUri;
779
+
780
+ return triggeredPipelines(pipelineFactory, scmConfig, branch, type, action, changedFiles, releaseName, ref);
781
+ })
782
+ .then(async pipelines => {
783
+ if (!pipelines || pipelines.length === 0) {
784
+ const message = `Skipping since Pipeline triggered by PRs against ${fullCheckoutUrl} does not exist`;
785
+
786
+ request.log(['webhook', hookId], message);
787
+
788
+ return h.response({ message }).code(204);
789
+ }
790
+
791
+ const options = {
792
+ name: `PR-${prNum}`,
793
+ hookId,
794
+ sha,
795
+ username,
796
+ scmConfig,
797
+ prRef,
798
+ prNum,
799
+ prTitle,
800
+ prSource,
801
+ changedFiles,
802
+ action: action.charAt(0).toUpperCase() + action.slice(1),
803
+ branch,
804
+ fullCheckoutUrl,
805
+ restrictPR,
806
+ chainPR,
807
+ pipelines,
808
+ ref,
809
+ releaseName,
810
+ meta
811
+ };
812
+
813
+ await batchUpdateAdmins({ userFactory, pipelines, username, scmContext, pipelineFactory });
814
+
815
+ switch (action) {
816
+ case 'opened':
817
+ case 'reopened':
818
+ return pullRequestOpened(options, request, h);
819
+ case 'synchronized':
820
+ return pullRequestSync(options, request, h);
821
+ case 'closed':
822
+ default:
823
+ return pullRequestClosed(options, request, h);
824
+ }
825
+ })
826
+ .catch(err => {
827
+ logger.error(`[${hookId}]: ${err}`);
828
+
829
+ throw err;
830
+ });
831
+ }
832
+
833
+ /**
834
+ * Create events for each pipeline
835
+ * @async createEvents
836
+ * @param {EventFactory} eventFactory To create event
837
+ * @param {UserFactory} userFactory To get user permission
838
+ * @param {PipelineFactory} pipelineFactory To use scm module
839
+ * @param {Array} pipelines The pipelines to start events
840
+ * @param {Object} parsed It has information to create event
841
+ * @param {String} [skipMessage] Message to skip starting builds
842
+ * @returns {Promise} Promise that resolves into events
843
+ */
844
+ async function createEvents(
845
+ eventFactory,
846
+ userFactory,
847
+ pipelineFactory,
848
+ pipelines,
849
+ parsed,
850
+ skipMessage,
851
+ scmConfigFromHook
852
+ ) {
853
+ const { action, branch, sha, username, scmContext, changedFiles, type, releaseName, ref } = parsed;
854
+ const events = [];
855
+ const meta = createMeta(parsed);
856
+
857
+ const pipelineTuples = await Promise.all(
858
+ pipelines.map(async p => {
859
+ const resolvedBranch = await p.branch;
860
+ let isReleaseOrTagFiltering = '';
861
+
862
+ if (action === 'release' || action === 'tag') {
863
+ isReleaseOrTagFiltering = isReleaseOrTagFilteringEnabled(action, p.workflowGraph);
864
+ }
865
+ const startFrom = determineStartFrom(
866
+ action,
867
+ type,
868
+ branch,
869
+ resolvedBranch,
870
+ releaseName,
871
+ ref,
872
+ isReleaseOrTagFiltering
873
+ );
874
+ const tuple = { branch: resolvedBranch, pipeline: p, startFrom };
875
+
876
+ return tuple;
877
+ })
878
+ );
879
+
880
+ const ignoreExtraTriggeredPipelines = pipelineTuples.filter(t => {
881
+ // empty event is not created when it is triggered by extra triggers (e.g. ~tag, ~release)
882
+ if (EXTRA_TRIGGERS.test(t.startFrom) && !hasTriggeredJob(t.pipeline, t.startFrom)) {
883
+ logger.warn(`Event not created: there are no jobs triggered by ${t.startFrom}`);
884
+
885
+ return false;
886
+ }
887
+
888
+ return true;
889
+ });
890
+
891
+ const eventConfigs = await Promise.all(
892
+ ignoreExtraTriggeredPipelines.map(async pTuple => {
893
+ try {
894
+ const pipelineBranch = pTuple.branch;
895
+ let isReleaseOrTagFiltering = '';
896
+
897
+ if (action === 'release' || action === 'tag') {
898
+ isReleaseOrTagFiltering = isReleaseOrTagFilteringEnabled(action, pTuple.pipeline.workflowGraph);
899
+ }
900
+ const startFrom = determineStartFrom(
901
+ action,
902
+ type,
903
+ branch,
904
+ pipelineBranch,
905
+ releaseName,
906
+ ref,
907
+ isReleaseOrTagFiltering
908
+ );
909
+ const token = await pTuple.pipeline.token;
910
+ const scmConfig = {
911
+ scmUri: pTuple.pipeline.scmUri,
912
+ token,
913
+ scmContext
914
+ };
915
+ // obtain pipeline's latest commit sha for branch specific job
916
+ let configPipelineSha = '';
917
+
918
+ try {
919
+ configPipelineSha = await pipelineFactory.scm.getCommitSha(scmConfig);
920
+ } catch (err) {
921
+ if (err.status >= 500) {
922
+ throw err;
923
+ } else {
924
+ logger.info(`skip create event for branch: ${pipelineBranch}`);
925
+ }
926
+ }
927
+ const eventConfig = {
928
+ pipelineId: pTuple.pipeline.id,
929
+ type: 'pipeline',
930
+ webhooks: true,
931
+ username,
932
+ scmContext,
933
+ startFrom,
934
+ sha,
935
+ configPipelineSha,
936
+ changedFiles,
937
+ baseBranch: branch,
938
+ causeMessage: `Merged by ${username}`,
939
+ meta,
940
+ releaseName,
941
+ ref
942
+ };
943
+
944
+ // Check is the webhook event is from a subscribed repo and
945
+ // set the jobs entry point to ~subscribe
946
+ if (uriTrimmer(scmConfigFromHook.scmUri) !== uriTrimmer(pTuple.pipeline.scmUri)) {
947
+ eventConfig.subscribedEvent = true;
948
+ eventConfig.startFrom = '~subscribe';
949
+ eventConfig.subscribedConfigSha = eventConfig.sha;
950
+
951
+ try {
952
+ eventConfig.sha = await pipelineFactory.scm.getCommitSha(scmConfig);
953
+ } catch (err) {
954
+ if (err.status >= 500) {
955
+ throw err;
956
+ } else {
957
+ logger.info(`skip create event for this subscribed trigger`);
958
+ }
959
+ }
960
+
961
+ try {
962
+ const commitInfo = await pipelineFactory.scm.decorateCommit({
963
+ scmUri: scmConfigFromHook.scmUri,
964
+ scmContext,
965
+ sha: eventConfig.subscribedConfigSha,
966
+ token
967
+ });
968
+
969
+ eventConfig.subscribedSourceUrl = commitInfo.url;
970
+ } catch (err) {
971
+ if (err.status >= 500) {
972
+ throw err;
973
+ } else {
974
+ logger.info(`skip create event for this subscribed trigger`);
975
+ }
976
+ }
977
+ }
978
+
979
+ if (skipMessage) {
980
+ eventConfig.skipMessage = skipMessage;
981
+ }
982
+
983
+ await updateAdmins(userFactory, username, scmContext, pTuple.pipeline, pipelineFactory);
984
+
985
+ return eventConfig;
986
+ } catch (err) {
987
+ logger.warn(`pipeline:${pTuple.pipeline.id} error in starting event`, err);
988
+
989
+ return null;
990
+ }
991
+ })
992
+ );
993
+
994
+ eventConfigs.forEach(eventConfig => {
995
+ if (eventConfig && eventConfig.configPipelineSha) {
996
+ events.push(eventFactory.create(eventConfig));
997
+ }
998
+ });
999
+
1000
+ return Promise.all(events);
1001
+ }
1002
+
1003
+ /**
1004
+ * Act on a Push event
1005
+ * - Should start a new main job
1006
+ * @method pushEvent
1007
+ * @param {Hapi.request} request Request from user
1008
+ * @param {Hapi.h} h Response toolkit
1009
+ * @param {Object} parsed It has information to create event
1010
+ * @param {String} token The token used to authenticate to the SCM
1011
+ * @param {String} [skipMessage] Message to skip starting builds
1012
+ */
1013
+ async function pushEvent(request, h, parsed, skipMessage, token) {
1014
+ const { eventFactory, pipelineFactory, userFactory } = request.server.app;
1015
+ const { hookId, checkoutUrl, branch, scmContext, type, action, changedFiles, releaseName, ref } = parsed;
1016
+ const fullCheckoutUrl = `${checkoutUrl}#${branch}`;
1017
+ const scmConfig = {
1018
+ scmUri: '',
1019
+ token: '',
1020
+ scmContext
1021
+ };
1022
+
1023
+ request.log(['webhook', hookId], `Push for ${fullCheckoutUrl}`);
1024
+
1025
+ try {
1026
+ scmConfig.token = token;
1027
+ scmConfig.scmUri = await pipelineFactory.scm.parseUrl({
1028
+ checkoutUrl: fullCheckoutUrl,
1029
+ token,
1030
+ scmContext
1031
+ });
1032
+
1033
+ const pipelines = await triggeredPipelines(
1034
+ pipelineFactory,
1035
+ scmConfig,
1036
+ branch,
1037
+ type,
1038
+ action,
1039
+ changedFiles,
1040
+ releaseName,
1041
+ ref
1042
+ );
1043
+ let events = [];
1044
+
1045
+ if (!pipelines || pipelines.length === 0) {
1046
+ request.log(['webhook', hookId], `Skipping since Pipeline ${fullCheckoutUrl} does not exist`);
1047
+ } else {
1048
+ events = await createEvents(
1049
+ eventFactory,
1050
+ userFactory,
1051
+ pipelineFactory,
1052
+ pipelines,
1053
+ parsed,
1054
+ skipMessage,
1055
+ scmConfig
1056
+ );
1057
+ }
1058
+
1059
+ const hasBuildEvents = events.filter(e => e.builds !== null);
1060
+
1061
+ if (hasBuildEvents.length === 0) {
1062
+ return h.response({ message: 'No jobs to start' }).code(204);
1063
+ }
1064
+
1065
+ hasBuildEvents.forEach(e => {
1066
+ request.log(['webhook', hookId, e.id], `Event ${e.id} started`);
1067
+ });
1068
+
1069
+ return h.response().code(201);
1070
+ } catch (err) {
1071
+ logger.error(`[${hookId}]: ${err}`);
1072
+
1073
+ throw err;
1074
+ }
1075
+ }
1076
+
1077
+ /** Execute scm.getCommitRefSha()
1078
+ * @method getCommitRefSha
1079
+ * @param {Object} scm
1080
+ * @param {String} token The token used to authenticate to the SCM
1081
+ * @param {String} ref The reference which we want
1082
+ * @param {String} checkoutUrl Scm checkout URL
1083
+ * @param {String} scmContext Scm which pipeline's repository exists in
1084
+ * @returns {Promise} Specific SHA1 commit to start the build with
1085
+ */
1086
+ async function getCommitRefSha({ scm, token, ref, refType, checkoutUrl, scmContext }) {
1087
+ // For example, git@github.com:screwdriver-cd/data-schema.git => screwdriver-cd, data-schema
1088
+ const owner = CHECKOUT_URL_SCHEMA_REGEXP.exec(checkoutUrl)[2];
1089
+ const repo = CHECKOUT_URL_SCHEMA_REGEXP.exec(checkoutUrl)[3];
1090
+
1091
+ return scm.getCommitRefSha({
1092
+ token,
1093
+ owner,
1094
+ repo,
1095
+ ref,
1096
+ refType,
1097
+ scmContext
1098
+ });
1099
+ }
1100
+
1101
+ /**
1102
+ * Start pipeline events with scm webhook config
1103
+ * @method startHookEvent
1104
+ * @param {Hapi.request} request Request from user
1105
+ * @param {Object} h Response toolkit
1106
+ * @param {Object} webhookConfig Configuration required to start events
1107
+ * @return {Promise}
1108
+ */
1109
+ async function startHookEvent(request, h, webhookConfig) {
1110
+ const { userFactory, pipelineFactory } = request.server.app;
1111
+ const { scm } = pipelineFactory;
1112
+ const ignoreUser = webhookConfig.pluginOptions.ignoreCommitsBy;
1113
+ let message = 'Unable to process this kind of event';
1114
+ let skipMessage;
1115
+ let parsedHookId = '';
1116
+
1117
+ const { type, hookId, username, scmContext, ref, checkoutUrl, action, prNum } = webhookConfig;
1118
+
1119
+ parsedHookId = hookId;
1120
+
1121
+ try {
1122
+ // skipping checks
1123
+ if (/\[(skip ci|ci skip)\]/.test(webhookConfig.lastCommitMessage)) {
1124
+ skipMessage = 'Skipping due to the commit message: [skip ci]';
1125
+ }
1126
+
1127
+ // if skip ci then don't return
1128
+ if (ignoreUser && ignoreUser.length !== 0 && !skipMessage) {
1129
+ const commitAuthors =
1130
+ Array.isArray(webhookConfig.commitAuthors) && webhookConfig.commitAuthors.length !== 0
1131
+ ? webhookConfig.commitAuthors
1132
+ : [username];
1133
+ const validCommitAuthors = commitAuthors.filter(author => !ignoreUser.includes(author));
1134
+
1135
+ if (!validCommitAuthors.length) {
1136
+ message = `Skipping because user ${username} is ignored`;
1137
+ request.log(['webhook', hookId], message);
1138
+
1139
+ return h.response({ message }).code(204);
1140
+ }
1141
+ }
1142
+
1143
+ const token = await obtainScmToken({ pluginOptions: webhookConfig.pluginOptions, userFactory, username, scmContext, scm });
1144
+
1145
+ if (action !== 'release' && action !== 'tag') {
1146
+ let scmUri;
1147
+
1148
+ if (type === 'pr') {
1149
+ scmUri = await scm.parseUrl({ checkoutUrl, token, scmContext });
1150
+ }
1151
+ webhookConfig.changedFiles = await scm.getChangedFiles({
1152
+ webhookConfig,
1153
+ type,
1154
+ token,
1155
+ scmContext,
1156
+ scmUri,
1157
+ prNum
1158
+ });
1159
+ request.log(['webhook', hookId], `Changed files are ${webhookConfig.changedFiles}`);
1160
+ } else {
1161
+ // The payload has no sha when webhook event is tag or release, so we need to get it.
1162
+ try {
1163
+ webhookConfig.sha = await getCommitRefSha({
1164
+ scm,
1165
+ token,
1166
+ ref,
1167
+ refType: 'tags',
1168
+ checkoutUrl,
1169
+ scmContext
1170
+ });
1171
+ } catch (err) {
1172
+ request.log(['webhook', hookId, 'getCommitRefSha'], err);
1173
+
1174
+ // there is a possibility of scm.getCommitRefSha() is not implemented yet
1175
+ return h.response({ message }).code(204);
1176
+ }
1177
+ }
1178
+
1179
+ if (type === 'pr') {
1180
+ // disregard skip ci for pull request events
1181
+ return pullRequestEvent(webhookConfig.pluginOptions, request, h, webhookConfig, token);
1182
+ }
1183
+
1184
+ return pushEvent(request, h, webhookConfig, skipMessage, token);
1185
+ } catch (err) {
1186
+ logger.error(`[${parsedHookId}]: ${err}`);
1187
+
1188
+ throw err;
1189
+ }
1190
+ }
1191
+
1192
+ module.exports = { startHookEvent };