From c978b4e086b5bd729c76f7c8784430619d67af33 Mon Sep 17 00:00:00 2001
From: Shaun Wilde <sawilde@users.noreply.github.com>
Date: Mon, 19 Sep 2022 18:12:52 +1000
Subject: [PATCH] feat(manager/asdf): add support for .tools-versions as used
 by asdf (#17166)

Co-authored-by: Rhys Arkins <rhys@arkins.net>
Co-authored-by: Michael Kriese <michael.kriese@visualon.de>
---
 lib/modules/manager/api.ts               |   2 +
 lib/modules/manager/asdf/extract.spec.ts | 144 +++++++++++++++++++++++
 lib/modules/manager/asdf/extract.ts      |  58 +++++++++
 lib/modules/manager/asdf/index.ts        |  11 ++
 lib/modules/manager/asdf/readme.md       |  10 ++
 5 files changed, 225 insertions(+)
 create mode 100644 lib/modules/manager/asdf/extract.spec.ts
 create mode 100644 lib/modules/manager/asdf/extract.ts
 create mode 100644 lib/modules/manager/asdf/index.ts
 create mode 100644 lib/modules/manager/asdf/readme.md

diff --git a/lib/modules/manager/api.ts b/lib/modules/manager/api.ts
index 0ae05622fc..89d9cc2508 100644
--- a/lib/modules/manager/api.ts
+++ b/lib/modules/manager/api.ts
@@ -1,6 +1,7 @@
 import * as ansible from './ansible';
 import * as ansibleGalaxy from './ansible-galaxy';
 import * as argoCD from './argocd';
+import * as asdf from './asdf';
 import * as azurePipelines from './azure-pipelines';
 import * as batect from './batect';
 import * as batectWrapper from './batect-wrapper';
@@ -83,6 +84,7 @@ export default api;
 api.set('ansible', ansible);
 api.set('ansible-galaxy', ansibleGalaxy);
 api.set('argocd', argoCD);
+api.set('asdf', asdf);
 api.set('azure-pipelines', azurePipelines);
 api.set('batect', batect);
 api.set('batect-wrapper', batectWrapper);
diff --git a/lib/modules/manager/asdf/extract.spec.ts b/lib/modules/manager/asdf/extract.spec.ts
new file mode 100644
index 0000000000..05d0d57152
--- /dev/null
+++ b/lib/modules/manager/asdf/extract.spec.ts
@@ -0,0 +1,144 @@
+import { extractPackageFile } from '.';
+
+describe('modules/manager/asdf/extract', () => {
+  describe('extractPackageFile()', () => {
+    it('returns a result', () => {
+      const res = extractPackageFile('nodejs 16.16.0\n');
+      expect(res).toEqual({
+        deps: [
+          {
+            currentValue: '16.16.0',
+            datasource: 'github-tags',
+            depName: 'node',
+            packageName: 'nodejs/node',
+            versioning: 'node',
+          },
+        ],
+      });
+    });
+
+    it('provides skipReason for lines with unsupported tooling', () => {
+      const res = extractPackageFile('unsupported 1.22.5\n');
+      expect(res).toEqual({
+        deps: [
+          {
+            depName: 'unsupported',
+            skipReason: 'unsupported-datasource',
+          },
+        ],
+      });
+    });
+
+    it('only captures the first version', () => {
+      const res = extractPackageFile('nodejs 16.16.0 16.15.1');
+      expect(res).toEqual({
+        deps: [
+          {
+            currentValue: '16.16.0',
+            datasource: 'github-tags',
+            depName: 'node',
+            packageName: 'nodejs/node',
+            versioning: 'node',
+          },
+        ],
+      });
+    });
+
+    it('can handle multiple tools in one file', () => {
+      const res = extractPackageFile('nodejs 16.16.0\ndummy 1.2.3');
+      expect(res).toEqual({
+        deps: [
+          {
+            currentValue: '16.16.0',
+            datasource: 'github-tags',
+            depName: 'node',
+            packageName: 'nodejs/node',
+            versioning: 'node',
+          },
+          {
+            depName: 'dummy',
+            skipReason: 'unsupported-datasource',
+          },
+        ],
+      });
+    });
+
+    describe('comment handling', () => {
+      const validComments = [
+        {
+          entry: 'nodejs 16.16.0 # tidy comment',
+          expect: '16.16.0',
+        },
+        {
+          entry: 'nodejs 16.16.0 #sloppy-comment',
+          expect: '16.16.0',
+        },
+      ];
+
+      describe.each(validComments)(
+        'ignores proper comments at the end of lines',
+        (data) => {
+          it(`entry: '${data.entry}'`, () => {
+            const res = extractPackageFile(data.entry);
+            expect(res).toEqual({
+              deps: [
+                {
+                  currentValue: data.expect,
+                  datasource: 'github-tags',
+                  depName: 'node',
+                  packageName: 'nodejs/node',
+                  versioning: 'node',
+                },
+              ],
+            });
+          });
+        }
+      );
+
+      it('invalid comment placements fail to parse', () => {
+        const res = extractPackageFile(
+          'nodejs 16.16.0# invalid comment spacing'
+        );
+        expect(res).toBeNull();
+      });
+
+      it('ignores lines that are just comments', () => {
+        const res = extractPackageFile('# this is a full line comment\n');
+        expect(res).toBeNull();
+      });
+
+      it('ignores comments across multiple lines', () => {
+        const res = extractPackageFile(
+          '# this is a full line comment\nnodejs 16.16.0 # this is a comment\n'
+        );
+        expect(res).toEqual({
+          deps: [
+            {
+              currentValue: '16.16.0',
+              datasource: 'github-tags',
+              depName: 'node',
+              packageName: 'nodejs/node',
+              versioning: 'node',
+            },
+          ],
+        });
+      });
+
+      it('ignores supported tooling with a renovate:ignore comment', () => {
+        const res = extractPackageFile('nodejs 16.16.0 # renovate:ignore\n');
+        expect(res).toEqual({
+          deps: [
+            {
+              currentValue: '16.16.0',
+              datasource: 'github-tags',
+              depName: 'node',
+              packageName: 'nodejs/node',
+              versioning: 'node',
+              skipReason: 'ignored',
+            },
+          ],
+        });
+      });
+    });
+  });
+});
diff --git a/lib/modules/manager/asdf/extract.ts b/lib/modules/manager/asdf/extract.ts
new file mode 100644
index 0000000000..2c206c90e3
--- /dev/null
+++ b/lib/modules/manager/asdf/extract.ts
@@ -0,0 +1,58 @@
+import is from '@sindresorhus/is';
+import { logger } from '../../../logger';
+import { isSkipComment } from '../../../util/ignore';
+import { regEx } from '../../../util/regex';
+import { GithubTagsDatasource } from '../../datasource/github-tags';
+import * as nodeVersioning from '../../versioning/node';
+import type { PackageDependency, PackageFile } from '../types';
+
+const upgradeableTooling: Record<
+  string,
+  Pick<
+    PackageDependency,
+    'depName' | 'datasource' | 'packageName' | 'versioning'
+  >
+> = {
+  nodejs: {
+    depName: 'node',
+    datasource: GithubTagsDatasource.id,
+    packageName: 'nodejs/node',
+    versioning: nodeVersioning.id,
+  },
+};
+
+export function extractPackageFile(content: string): PackageFile | null {
+  logger.trace('asdf.extractPackageFile()');
+
+  const regex = regEx(
+    /^(?<toolName>(\w+)) (?<version>[^\s#]+)(?: [^\s#]+)* *(?: #(?<comment>.*))?$/gm
+  );
+
+  const deps: PackageDependency[] = [];
+
+  for (const groups of [...content.matchAll(regex)]
+    .map((m) => m.groups)
+    .filter(is.truthy)) {
+    const supportedTool = upgradeableTooling[groups.toolName];
+    if (supportedTool) {
+      const dep: PackageDependency = {
+        currentValue: groups.version.trim(),
+        ...supportedTool,
+      };
+      if (isSkipComment((groups.comment ?? '').trim())) {
+        dep.skipReason = 'ignored';
+      }
+
+      deps.push(dep);
+    } else {
+      const dep: PackageDependency = {
+        depName: groups.toolName.trim(),
+        skipReason: 'unsupported-datasource',
+      };
+
+      deps.push(dep);
+    }
+  }
+
+  return deps.length ? { deps } : null;
+}
diff --git a/lib/modules/manager/asdf/index.ts b/lib/modules/manager/asdf/index.ts
new file mode 100644
index 0000000000..df7dcc8c60
--- /dev/null
+++ b/lib/modules/manager/asdf/index.ts
@@ -0,0 +1,11 @@
+import { GithubTagsDatasource } from '../../datasource/github-tags';
+
+export { extractPackageFile } from './extract';
+
+export const displayName = 'asdf';
+
+export const defaultConfig = {
+  fileMatch: ['(^|/)\\.tools-versions$'],
+};
+
+export const supportedDatasources = [GithubTagsDatasource.id];
diff --git a/lib/modules/manager/asdf/readme.md b/lib/modules/manager/asdf/readme.md
new file mode 100644
index 0000000000..f593fcf5ee
--- /dev/null
+++ b/lib/modules/manager/asdf/readme.md
@@ -0,0 +1,10 @@
+Keeps the [asdf](https://asdf-vm.com/manage/configuration.html#tool-versions)
+`.tools-versions` file updated.
+
+Because `asdf` supports the version management of many different tools, specific tool support needs to be added one by one.
+
+Only the following tools are currently supported
+
+- [nodejs](https://github.com/asdf-vm/asdf-nodejs)
+
+NOTE: Because `.tools-versions` can support fallback versions only the first version entry for each supported tool is managed
-- 
GitLab