From 21ff27d5ab1003616b6a16c26ee1a59b91e8b908 Mon Sep 17 00:00:00 2001
From: fredrondina <fredrondina96@gmail.com>
Date: Tue, 16 Aug 2022 10:36:05 -0500
Subject: [PATCH] feat(gitlab-ci): ref add logic for updating non top level
 `include`s (#16819)

Co-authored-by: Fred Rondina <fred.rondina@daveramsey.com>
Co-authored-by: Michael Kriese <michael.kriese@visualon.de>
Co-authored-by: Rhys Arkins <rhys@arkins.net>
---
 .../__fixtures__/gitlab-ci.4.yaml             | 13 ++++
 .../manager/gitlabci-include/common.spec.ts   | 60 ++++++++++++++++
 .../manager/gitlabci-include/common.ts        | 34 +++++++++
 .../manager/gitlabci-include/extract.spec.ts  | 27 +++++++
 .../manager/gitlabci-include/extract.ts       | 70 +++++++++++++------
 lib/modules/manager/gitlabci/common.spec.ts   | 16 +++++
 lib/modules/manager/gitlabci/common.ts        |  8 +++
 lib/modules/manager/gitlabci/extract.ts       | 13 ++--
 lib/modules/manager/gitlabci/types.ts         | 26 ++++++-
 9 files changed, 237 insertions(+), 30 deletions(-)
 create mode 100644 lib/modules/manager/gitlabci-include/__fixtures__/gitlab-ci.4.yaml
 create mode 100644 lib/modules/manager/gitlabci-include/common.spec.ts
 create mode 100644 lib/modules/manager/gitlabci-include/common.ts
 create mode 100644 lib/modules/manager/gitlabci/common.spec.ts
 create mode 100644 lib/modules/manager/gitlabci/common.ts

diff --git a/lib/modules/manager/gitlabci-include/__fixtures__/gitlab-ci.4.yaml b/lib/modules/manager/gitlabci-include/__fixtures__/gitlab-ci.4.yaml
new file mode 100644
index 0000000000..ec7d4848a0
--- /dev/null
+++ b/lib/modules/manager/gitlabci-include/__fixtures__/gitlab-ci.4.yaml
@@ -0,0 +1,13 @@
+---
+include:
+- project: mikebryant/include-source-example
+  file: /template.yaml
+  ref: 1.0.0
+
+trigger-my-job:
+  extends: .extend-trigger-job
+  trigger:
+    include:
+      - project: mikebryant/include-source-example
+        file: /template.yaml
+        ref: master
diff --git a/lib/modules/manager/gitlabci-include/common.spec.ts b/lib/modules/manager/gitlabci-include/common.spec.ts
new file mode 100644
index 0000000000..a1c2dcd024
--- /dev/null
+++ b/lib/modules/manager/gitlabci-include/common.spec.ts
@@ -0,0 +1,60 @@
+import { load } from 'js-yaml';
+import { Fixtures } from '../../../../test/fixtures';
+import type { GitlabPipeline } from '../gitlabci/types';
+import { replaceReferenceTags } from '../gitlabci/utils';
+import {
+  filterIncludeFromGitlabPipeline,
+  isGitlabIncludeLocal,
+  isGitlabIncludeProject,
+  isNonEmptyObject,
+} from './common';
+
+const yamlFileMultiConfig = Fixtures.get('gitlab-ci.1.yaml');
+const pipeline = load(
+  replaceReferenceTags(yamlFileMultiConfig)
+) as GitlabPipeline;
+const includeLocal = { local: 'something' };
+const includeProject = { project: 'something' };
+
+describe('modules/manager/gitlabci-include/common', () => {
+  describe('filterIncludeFromGitlabPipeline()', () => {
+    it('returns GitlabPipeline without top level include key', () => {
+      expect(pipeline).toHaveProperty('include');
+      const filtered_pipeline = filterIncludeFromGitlabPipeline(pipeline);
+      expect(filtered_pipeline).not.toHaveProperty('include');
+      expect(filtered_pipeline).toEqual({
+        script: [null, null],
+      });
+    });
+  });
+
+  describe('isGitlabIncludeLocal()', () => {
+    it('returns true if GitlabInclude is GitlabIncludeLocal', () => {
+      expect(isGitlabIncludeLocal(includeLocal)).toBe(true);
+    });
+
+    it('returns false if GitlabInclude is not GitlabIncludeLocal', () => {
+      expect(isGitlabIncludeLocal(includeProject)).toBe(false);
+    });
+  });
+
+  describe('isGitlabIncludeProject()', () => {
+    it('returns true if GitlabInclude is GitlabIncludeProject', () => {
+      expect(isGitlabIncludeProject(includeProject)).toBe(true);
+    });
+
+    it('returns false if GitlabInclude is not GitlabIncludeProject', () => {
+      expect(isGitlabIncludeProject(includeLocal)).toBe(false);
+    });
+  });
+
+  describe('isNonEmptyObject()', () => {
+    it('returns true if not empty', () => {
+      expect(isNonEmptyObject({ attribute1: 1 })).toBe(true);
+    });
+
+    it('returns false if empty', () => {
+      expect(isNonEmptyObject({})).toBe(false);
+    });
+  });
+});
diff --git a/lib/modules/manager/gitlabci-include/common.ts b/lib/modules/manager/gitlabci-include/common.ts
new file mode 100644
index 0000000000..da163cb0ea
--- /dev/null
+++ b/lib/modules/manager/gitlabci-include/common.ts
@@ -0,0 +1,34 @@
+import is from '@sindresorhus/is';
+import type {
+  GitlabInclude,
+  GitlabIncludeLocal,
+  GitlabIncludeProject,
+  GitlabPipeline,
+} from '../gitlabci/types';
+
+export function isNonEmptyObject(obj: any): boolean {
+  return is.object(obj) && Object.keys(obj).length !== 0;
+}
+
+export function filterIncludeFromGitlabPipeline(
+  pipeline: GitlabPipeline
+): GitlabPipeline {
+  const pipeline_without_include = {} as GitlabPipeline;
+  for (const key of Object.keys(pipeline).filter((key) => key !== 'include')) {
+    const pipeline_key = key as keyof typeof pipeline;
+    pipeline_without_include[pipeline_key] = pipeline[pipeline_key];
+  }
+  return pipeline_without_include;
+}
+
+export function isGitlabIncludeProject(
+  include: GitlabInclude
+): include is GitlabIncludeProject {
+  return !is.undefined((include as GitlabIncludeProject).project);
+}
+
+export function isGitlabIncludeLocal(
+  include: GitlabInclude
+): include is GitlabIncludeLocal {
+  return !is.undefined((include as GitlabIncludeLocal).local);
+}
diff --git a/lib/modules/manager/gitlabci-include/extract.spec.ts b/lib/modules/manager/gitlabci-include/extract.spec.ts
index 1cf0dd2fda..9ebd9b8c57 100644
--- a/lib/modules/manager/gitlabci-include/extract.spec.ts
+++ b/lib/modules/manager/gitlabci-include/extract.spec.ts
@@ -5,6 +5,7 @@ import { extractPackageFile } from '.';
 const yamlFileMultiConfig = Fixtures.get('gitlab-ci.1.yaml');
 const yamlFileSingleConfig = Fixtures.get('gitlab-ci.2.yaml');
 const yamlWithEmptyIncludeConfig = Fixtures.get('gitlab-ci.3.yaml');
+const yamlWithTriggerRef = Fixtures.get('gitlab-ci.4.yaml');
 
 describe('modules/manager/gitlabci-include/extract', () => {
   describe('extractPackageFile()', () => {
@@ -29,6 +30,32 @@ describe('modules/manager/gitlabci-include/extract', () => {
       expect(res?.deps).toHaveLength(3);
     });
 
+    it('extracts multiple embedded include blocks', () => {
+      const res = extractPackageFile(yamlWithTriggerRef);
+      expect(res?.deps).toHaveLength(2);
+      expect(res?.deps).toMatchObject([
+        {
+          currentValue: 'master',
+          datasource: 'gitlab-tags',
+          depName: 'mikebryant/include-source-example',
+        },
+        {
+          currentValue: '1.0.0',
+          datasource: 'gitlab-tags',
+          depName: 'mikebryant/include-source-example',
+        },
+      ]);
+    });
+
+    it('ignores includes without project and file keys', () => {
+      const includeWithoutProjectRef = `include:
+      - 'https://gitlab.com/mikebryant/include-source-example.yml'
+      - remote: 'https://gitlab.com/mikebryant/include-source-example.yml'
+      - local: mikebryant/include-source-example`;
+      const res = extractPackageFile(includeWithoutProjectRef);
+      expect(res).toBeNull();
+    });
+
     it('normalizes configured endpoints', () => {
       const endpoints = [
         'http://gitlab.test/api/v4',
diff --git a/lib/modules/manager/gitlabci-include/extract.ts b/lib/modules/manager/gitlabci-include/extract.ts
index ea6be88efc..1fa8c9d6e7 100644
--- a/lib/modules/manager/gitlabci-include/extract.ts
+++ b/lib/modules/manager/gitlabci-include/extract.ts
@@ -4,14 +4,22 @@ import { GlobalConfig } from '../../../config/global';
 import { logger } from '../../../logger';
 import { regEx } from '../../../util/regex';
 import { GitlabTagsDatasource } from '../../datasource/gitlab-tags';
+import type {
+  GitlabInclude,
+  GitlabIncludeProject,
+  GitlabPipeline,
+} from '../gitlabci/types';
 import { replaceReferenceTags } from '../gitlabci/utils';
 import type { PackageDependency, PackageFile } from '../types';
+import {
+  filterIncludeFromGitlabPipeline,
+  isGitlabIncludeProject,
+  isNonEmptyObject,
+} from './common';
 
-function extractDepFromIncludeFile(includeObj: {
-  file: any;
-  project: string;
-  ref: string;
-}): PackageDependency {
+function extractDepFromIncludeFile(
+  includeObj: GitlabIncludeProject
+): PackageDependency {
   const dep: PackageDependency = {
     datasource: GitlabTagsDatasource.id,
     depName: includeObj.project,
@@ -25,28 +33,50 @@ function extractDepFromIncludeFile(includeObj: {
   return dep;
 }
 
+function getIncludeProjectsFromInclude(
+  includeValue: GitlabInclude[] | GitlabInclude
+): GitlabIncludeProject[] {
+  const includes = is.array(includeValue) ? includeValue : [includeValue];
+
+  // Filter out includes that dont have a file & project.
+  return includes.filter(isGitlabIncludeProject);
+}
+
+function getAllIncludeProjects(data: GitlabPipeline): GitlabIncludeProject[] {
+  // If Array, search each element.
+  if (is.array(data)) {
+    return (data as GitlabPipeline[])
+      .filter(isNonEmptyObject)
+      .map(getAllIncludeProjects)
+      .flat();
+  }
+
+  const childrenData = Object.values(filterIncludeFromGitlabPipeline(data))
+    .filter(isNonEmptyObject)
+    .map(getAllIncludeProjects)
+    .flat();
+
+  // Process include key.
+  if (data.include) {
+    childrenData.push(...getIncludeProjectsFromInclude(data.include));
+  }
+  return childrenData;
+}
+
 export function extractPackageFile(content: string): PackageFile | null {
   const deps: PackageDependency[] = [];
   const { platform, endpoint } = GlobalConfig.get();
   try {
-    // TODO: fix me (#9610)
-    const doc: any = load(replaceReferenceTags(content), {
+    const doc = load(replaceReferenceTags(content), {
       json: true,
-    });
-    let includes;
-    if (doc?.include && is.array(doc.include)) {
-      includes = doc.include;
-    } else {
-      includes = [doc.include];
-    }
+    }) as GitlabPipeline;
+    const includes = getAllIncludeProjects(doc);
     for (const includeObj of includes) {
-      if (includeObj?.file && includeObj.project) {
-        const dep = extractDepFromIncludeFile(includeObj);
-        if (platform === 'gitlab' && endpoint) {
-          dep.registryUrls = [endpoint.replace(regEx(/\/api\/v4\/?/), '')];
-        }
-        deps.push(dep);
+      const dep = extractDepFromIncludeFile(includeObj);
+      if (platform === 'gitlab' && endpoint) {
+        dep.registryUrls = [endpoint.replace(regEx(/\/api\/v4\/?/), '')];
       }
+      deps.push(dep);
     }
   } catch (err) /* istanbul ignore next */ {
     if (err.stack?.startsWith('YAMLException:')) {
diff --git a/lib/modules/manager/gitlabci/common.spec.ts b/lib/modules/manager/gitlabci/common.spec.ts
new file mode 100644
index 0000000000..d40f202d26
--- /dev/null
+++ b/lib/modules/manager/gitlabci/common.spec.ts
@@ -0,0 +1,16 @@
+import { isGitlabIncludeLocal } from './common';
+
+const includeLocal = { local: 'something' };
+const includeProject = { project: 'something' };
+
+describe('modules/manager/gitlabci/common', () => {
+  describe('isGitlabIncludeLocal()', () => {
+    it('returns true if GitlabInclude is GitlabIncludeLocal', () => {
+      expect(isGitlabIncludeLocal(includeLocal)).toBe(true);
+    });
+
+    it('returns false if GitlabInclude is not GitlabIncludeLocal', () => {
+      expect(isGitlabIncludeLocal(includeProject)).toBe(false);
+    });
+  });
+});
diff --git a/lib/modules/manager/gitlabci/common.ts b/lib/modules/manager/gitlabci/common.ts
new file mode 100644
index 0000000000..baa89d32dd
--- /dev/null
+++ b/lib/modules/manager/gitlabci/common.ts
@@ -0,0 +1,8 @@
+import is from '@sindresorhus/is';
+import type { GitlabInclude, GitlabIncludeLocal } from '../gitlabci/types';
+
+export function isGitlabIncludeLocal(
+  include: GitlabInclude
+): include is GitlabIncludeLocal {
+  return !is.undefined((include as GitlabIncludeLocal).local);
+}
diff --git a/lib/modules/manager/gitlabci/extract.ts b/lib/modules/manager/gitlabci/extract.ts
index ff674a2bff..32abce4ff0 100644
--- a/lib/modules/manager/gitlabci/extract.ts
+++ b/lib/modules/manager/gitlabci/extract.ts
@@ -4,6 +4,7 @@ import { logger } from '../../../logger';
 import { readLocalFile } from '../../../util/fs';
 import { trimLeadingSlash } from '../../../util/url';
 import type { ExtractConfig, PackageDependency, PackageFile } from '../types';
+import { isGitlabIncludeLocal } from './common';
 import type { GitlabPipeline, Image, Job, Services } from './types';
 import { getGitlabDep, replaceReferenceTags } from './utils';
 
@@ -144,13 +145,11 @@ export async function extractAllPackageFiles(
     }
 
     if (is.array(doc?.include)) {
-      for (const includeObj of doc.include) {
-        if (is.string(includeObj.local)) {
-          const fileObj = trimLeadingSlash(includeObj.local);
-          if (!seen.has(fileObj)) {
-            seen.add(fileObj);
-            filesToExamine.push(fileObj);
-          }
+      for (const includeObj of doc.include.filter(isGitlabIncludeLocal)) {
+        const fileObj = trimLeadingSlash(includeObj.local);
+        if (!seen.has(fileObj)) {
+          seen.add(fileObj);
+          filesToExamine.push(fileObj);
         }
       }
     } else if (is.string(doc?.include)) {
diff --git a/lib/modules/manager/gitlabci/types.ts b/lib/modules/manager/gitlabci/types.ts
index 18aabed7d9..9f8d7726b2 100644
--- a/lib/modules/manager/gitlabci/types.ts
+++ b/lib/modules/manager/gitlabci/types.ts
@@ -1,9 +1,23 @@
-export interface GitlabInclude {
-  local?: string;
+export interface GitlabIncludeLocal {
+  local: string;
+}
+
+export interface GitlabIncludeProject {
+  project: string;
+  file?: string;
+  ref?: string;
+}
+
+export interface GitlabIncludeRemote {
+  remote: string;
+}
+
+export interface GitlabIncludeTemplate {
+  template: string;
 }
 
 export interface GitlabPipeline {
-  include?: GitlabInclude[] | string;
+  include?: GitlabInclude[] | GitlabInclude;
 }
 
 export interface ImageObject {
@@ -20,3 +34,9 @@ export interface Job {
 }
 export type Image = ImageObject | string;
 export type Services = (string | ServicesObject)[];
+export type GitlabInclude =
+  | GitlabIncludeLocal
+  | GitlabIncludeProject
+  | GitlabIncludeRemote
+  | GitlabIncludeTemplate
+  | string;
-- 
GitLab