diff options
Diffstat (limited to 'app/assets/javascripts/ci/pipeline_details/utils/parsing_utils.js')
-rw-r--r-- | app/assets/javascripts/ci/pipeline_details/utils/parsing_utils.js | 182 |
1 files changed, 182 insertions, 0 deletions
diff --git a/app/assets/javascripts/ci/pipeline_details/utils/parsing_utils.js b/app/assets/javascripts/ci/pipeline_details/utils/parsing_utils.js new file mode 100644 index 00000000000..0a2a6d16498 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/utils/parsing_utils.js @@ -0,0 +1,182 @@ +import { memoize } from 'lodash'; +import { EXPLICIT_NEEDS_PROPERTY, NEEDS_PROPERTY } from '../constants'; +import { createSankey } from '../dag/utils/drawing_utils'; +import { createNodeDict } from './index'; + +/* + A peformant alternative to lodash's isEqual. Because findIndex always finds + the first instance of a match, if the found index is not the first, we know + it is in fact a duplicate. +*/ +const deduplicate = (item, itemIndex, arr) => { + const foundIdx = arr.findIndex((test) => { + return test.source === item.source && test.target === item.target; + }); + + return foundIdx === itemIndex; +}; + +export const makeLinksFromNodes = (nodes, nodeDict, { needsKey = NEEDS_PROPERTY } = {}) => { + const constantLinkValue = 10; // all links are the same weight + return nodes + .map(({ jobs, name: groupName }) => + jobs.map((job) => { + const needs = job[needsKey] || []; + + return needs.reduce((acc, needed) => { + // It's possible that we have an optional job, which + // is being needed by another job. In that scenario, + // the needed job doesn't exist, so we don't want to + // create link for it. + if (nodeDict[needed]?.name) { + acc.push({ + source: nodeDict[needed].name, + target: groupName, + value: constantLinkValue, + }); + } + + return acc; + }, []); + }), + ) + .flat(2); +}; + +export const getAllAncestors = (nodes, nodeDict) => { + const needs = nodes + .map((node) => { + return nodeDict[node]?.needs || ''; + }) + .flat() + .filter(Boolean) + .filter(deduplicate); + + if (needs.length) { + return [...needs, ...getAllAncestors(needs, nodeDict)]; + } + + return []; +}; + +export const filterByAncestors = (links, nodeDict) => + links.filter(({ target, source }) => { + /* + + for every link, check out it's target + for every target, get the target node's needs + then drop the current link source from that list + + call a function to get all ancestors, recursively + is the current link's source in the list of all parents? + then we drop this link + + */ + const targetNode = target; + const targetNodeNeeds = nodeDict[targetNode].needs; + const targetNodeNeedsMinusSource = targetNodeNeeds.filter((need) => need !== source); + const allAncestors = getAllAncestors(targetNodeNeedsMinusSource, nodeDict); + return !allAncestors.includes(source); + }); + +export const parseData = (nodes, { needsKey = NEEDS_PROPERTY } = {}) => { + const nodeDict = createNodeDict(nodes, { needsKey }); + const allLinks = makeLinksFromNodes(nodes, nodeDict, { needsKey }); + const filteredLinks = allLinks.filter(deduplicate); + const links = filterByAncestors(filteredLinks, nodeDict); + + return { nodes, links }; +}; + +/* + The number of nodes in the most populous generation drives the height of the graph. +*/ + +export const getMaxNodes = (nodes) => { + const counts = nodes.reduce((acc, { layer }) => { + if (!acc[layer]) { + acc[layer] = 0; + } + + acc[layer] += 1; + + return acc; + }, []); + + return Math.max(...counts); +}; + +/* + Because we cannot know if a node is part of a relationship until after we + generate the links with createSankey, this function is used after the first call + to find nodes that have no relations. +*/ + +export const removeOrphanNodes = (sankeyfiedNodes) => { + return sankeyfiedNodes.filter((node) => node.sourceLinks.length || node.targetLinks.length); +}; + +/* + This utility accepts unwrapped pipeline data in the format returned from + our standard pipeline GraphQL query and returns a list of names by layer + for the layer view. It can be combined with the stageLookup on the pipeline + to generate columns by layer. +*/ + +export const listByLayers = ({ stages }) => { + const arrayOfJobs = stages.flatMap(({ groups }) => groups); + const parsedData = parseData(arrayOfJobs); + const explicitParsedData = parseData(arrayOfJobs, { needsKey: EXPLICIT_NEEDS_PROPERTY }); + const dataWithLayers = createSankey()(explicitParsedData); + + const pipelineLayers = dataWithLayers.nodes.reduce((acc, { layer, name }) => { + /* sort groups by layer */ + + if (!acc[layer]) { + acc[layer] = []; + } + + acc[layer].push(name); + + return acc; + }, []); + + return { + linksData: parsedData.links, + numGroups: arrayOfJobs.length, + pipelineLayers, + }; +}; + +export const generateColumnsFromLayersListBare = ({ stages, stagesLookup }, pipelineLayers) => { + return pipelineLayers.map((layers, idx) => { + /* + Look up the groups in each layer, + then add each set of layer groups to a stage-like object. + */ + + const groups = layers.map((id) => { + const { stageIdx, groupIdx } = stagesLookup[id]; + return stages[stageIdx]?.groups?.[groupIdx]; + }); + + return { + name: '', + id: `layer-${idx}`, + status: { action: null }, + groups: groups.filter(Boolean), + }; + }); +}; + +export const generateColumnsFromLayersListMemoized = memoize(generateColumnsFromLayersListBare); + +export const keepLatestDownstreamPipelines = (downstreamPipelines = []) => { + return downstreamPipelines.filter((pipeline) => { + if (pipeline.source_job) { + return !pipeline?.source_job?.retried || false; + } + + return !pipeline?.sourceJob?.retried || false; + }); +}; |