diff --git a/README.md b/README.md index 3fe56f5..3d81a47 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ npm install screwdriver-workflow-parser ``` ``` -const { getWorkflow, getNextJobs } = require('screwdriver-workflow-parser'); +const { getWorkflow, getNextJobs, hasCycle } = require('screwdriver-workflow-parser'); // Calculate the directed graph workflow from a pipeline config (and parse legacy workflows) const workflowGraph = getWorkflow(pipelineConfig, { useLegacy: true }); @@ -26,7 +26,12 @@ const workflowGraph = getWorkflow(pipelineConfig, { useLegacy: true }); const commitJobsToTrigger = getNextJobs(workflowGraph, { trigger: '~commit' }); // Get a list of job names to start as a result of a pull-request event, e.g. [ 'PR-123:a' ] -const prJobsToTrigger = getNextJobs(workflowGraph, { trigger: '~pr', prNum: 123 }); +const prJobsToTrigger = getNextJobs(workflowGraph, { trigger: '~pr', prNum: 123 }); + +// Check to see if a given workflow graph has a loop in it. A -> B -> A +if (hasCycle(workflowGraph)) { + console.error('Graph contains a loop.'); +} ``` ## Testing diff --git a/lib/hasCycle.js b/lib/hasCycle.js new file mode 100644 index 0000000..d9c7f75 --- /dev/null +++ b/lib/hasCycle.js @@ -0,0 +1,42 @@ +'use strict'; + +const getNextJobs = require('./getNextJobs'); + +/** + * Recursively verify if there is a cycle starting from job + * @method walk + * @param {Object} workflowGraph Directed graph representation of workflow + * @param {String} jobName Job Name + * @param {Set} visitedJobs Unique list of visited jobs + * @return {Boolean} True if a cycle detected + */ +const walk = (workflowGraph, jobName, visitedJobs) => { + visitedJobs.add(jobName); + + const triggerList = getNextJobs(workflowGraph, { trigger: jobName, prNum: 1 }); + + // Hit a leaf node, must be good + if (triggerList.length === 0) { + return false; + } + + // Check to see if we visited this job before + if (triggerList.some(name => visitedJobs.has(name))) { + return true; + } + + // recursively walk starting from the new jobs + return triggerList.some(name => walk(workflowGraph, name, visitedJobs)); +}; + +/** + * Calculate if the workflow contains a cycle, e.g. A -> B -> A + * @method hasCycle + * @param {Object} workflowGraph Graph representation of workflow + * @return {Boolean} True if a cycle exists anywhere in the workflow + */ +const hasCycle = workflowGraph => + // Check from all the nodes to capture deteached workflows + workflowGraph.nodes.some(node => walk(workflowGraph, node.name, new Set())); + +module.exports = hasCycle; diff --git a/test/lib/hasCycle.test.js b/test/lib/hasCycle.test.js new file mode 100644 index 0000000..52ea2bf --- /dev/null +++ b/test/lib/hasCycle.test.js @@ -0,0 +1,87 @@ +'use strict'; + +const assert = require('chai').assert; +const hasCycle = require('../../lib/hasCycle'); + +describe('hasCyles', () => { + it('should return true if a workflow has a cycle', () => { + const workflow = { + nodes: [ + { name: '~pr' }, + { name: '~commit' }, + { name: 'A' }, + { name: 'B' }, + { name: 'C' }, + { name: 'D' }, + { name: 'E' }, + { name: 'F' } + ], + edges: [ + { src: '~commit', dest: 'A' }, // start + { src: 'A', dest: 'B' }, // parallel + { src: 'A', dest: 'C' }, // parallel + { src: 'B', dest: 'D' }, // serial + { src: 'C', dest: 'E' }, // serial + { src: 'D', dest: 'F', join: true }, // join + { src: 'E', dest: 'F', join: true }, // join + { src: 'F', dest: 'A' } // cycle + ] + }; + + assert.isTrue(hasCycle(workflow)); + }); + + it('should return true if a detached workflow has a cycle', () => { + const workflow = { + nodes: [ + { name: '~pr' }, + { name: '~commit' }, + { name: 'A' }, + { name: 'B' }, + { name: 'C' }, + { name: 'D' } + ], + edges: [ + { src: '~commit', dest: 'A' }, // start + { src: 'A', dest: 'B' }, // end + { src: 'C', dest: 'D' }, // detached start + { src: 'D', dest: 'C' } // detached cycle + ] + }; + + assert.isTrue(hasCycle(workflow)); + }); + + it('should return true if a job requires itself', () => { + const workflow = { + nodes: [ + { name: '~pr' }, + { name: '~commit' }, + { name: 'A' } + ], + edges: [ + { src: '~commit', dest: 'A' }, // start + { src: 'A', dest: 'A' } // cycle + ] + }; + + assert.isTrue(hasCycle(workflow)); + }); + + it('should return false if a workflow does not have a cycle', () => { + const workflow = { + nodes: [ + { name: '~pr' }, + { name: '~commit' }, + { name: 'A' }, + { name: 'B' } + ], + edges: [ + { src: '~commit', dest: 'A' }, // start + { src: 'A', dest: 'B' } // end + ] + }; + + assert.isFalse(hasCycle(workflow)); + }); +});