From 605f35c45c817f92cfd8aaecc2eb1513098d99d9 Mon Sep 17 00:00:00 2001
From: Yun Lai <ylai@squareup.com>
Date: Thu, 7 Jul 2022 23:30:22 +1000
Subject: [PATCH] feat: add versioning for Hermit package manager (#16256)

* feat: add versioning for Hermit package manager

* Update lib/modules/versioning/hermit/index.ts

Co-authored-by: Jamie Magee <jamie.magee@gmail.com>

* Update lib/modules/versioning/hermit/index.ts

Co-authored-by: Jamie Magee <jamie.magee@gmail.com>

* Update index.ts index.spec.ts and readme.md according to PR comments

* fix: fix versioning test double negation and _parseVersion function which is just for testing

* fix: simplify hermit versioning implementation as suggested

* fix: use _compare to simplify versioning implementation

* fix: reword version in hermit versioning and make _isChannel & _getChannel static

* fix: remove duplicated title in test and make _config readonly

Co-authored-by: Jamie Magee <jamie.magee@gmail.com>
Co-authored-by: Michael Kriese <michael.kriese@visualon.de>
---
 lib/modules/versioning/api.ts               |   2 +
 lib/modules/versioning/hermit/index.spec.ts | 196 ++++++++++++++++++++
 lib/modules/versioning/hermit/index.ts      | 136 ++++++++++++++
 lib/modules/versioning/hermit/readme.md     |  13 ++
 lib/modules/versioning/regex/index.ts       |   2 +-
 5 files changed, 348 insertions(+), 1 deletion(-)
 create mode 100644 lib/modules/versioning/hermit/index.spec.ts
 create mode 100644 lib/modules/versioning/hermit/index.ts
 create mode 100644 lib/modules/versioning/hermit/readme.md

diff --git a/lib/modules/versioning/api.ts b/lib/modules/versioning/api.ts
index 93ceef3a51..83fb40fa00 100644
--- a/lib/modules/versioning/api.ts
+++ b/lib/modules/versioning/api.ts
@@ -8,6 +8,7 @@ import * as git from './git';
 import * as gradle from './gradle';
 import * as hashicorp from './hashicorp';
 import * as helm from './helm';
+import * as hermit from './hermit';
 import * as hex from './hex';
 import * as ivy from './ivy';
 import * as loose from './loose';
@@ -40,6 +41,7 @@ api.set('git', git.api);
 api.set('gradle', gradle.api);
 api.set('hashicorp', hashicorp.api);
 api.set('helm', helm.api);
+api.set('hermit', hermit.api);
 api.set('hex', hex.api);
 api.set('ivy', ivy.api);
 api.set('loose', loose.api);
diff --git a/lib/modules/versioning/hermit/index.spec.ts b/lib/modules/versioning/hermit/index.spec.ts
new file mode 100644
index 0000000000..5de95b044f
--- /dev/null
+++ b/lib/modules/versioning/hermit/index.spec.ts
@@ -0,0 +1,196 @@
+import { HermitVersioning } from './index';
+
+describe('modules/versioning/hermit/index', () => {
+  const versioning = new HermitVersioning();
+
+  test.each`
+    version      | expected
+    ${'1'}       | ${true}
+    ${'1.2'}     | ${true}
+    ${'@1'}      | ${false}
+    ${'@1.2'}    | ${false}
+    ${'@1.2.3'}  | ${false}
+    ${'@latest'} | ${false}
+    ${'@stable'} | ${false}
+  `('isStable("$version") === $expected', ({ version, expected }) => {
+    expect(versioning.isStable(version)).toBe(expected);
+  });
+
+  test.each`
+    version                     | expected
+    ${'1'}                      | ${true}
+    ${'1rc1'}                   | ${true}
+    ${'1-foo'}                  | ${true}
+    ${'1+bar'}                  | ${true}
+    ${'1.2'}                    | ${true}
+    ${'1.2-foo'}                | ${true}
+    ${'1.2+bar'}                | ${true}
+    ${'1.2.3'}                  | ${true}
+    ${'1.2.3rc1'}               | ${true}
+    ${'1.2.3-foo'}              | ${true}
+    ${'1.2.3+bar'}              | ${true}
+    ${'17.0.1_12'}              | ${true}
+    ${'17.0.1_12+m1'}           | ${true}
+    ${'17.0.1_12+m1'}           | ${true}
+    ${'11.0.11_9-zulu11.48.21'} | ${true}
+    ${'1.2-kotlin.3'}           | ${true}
+    ${'@1'}                     | ${true}
+    ${'@1.2'}                   | ${true}
+    ${'@1.2.3'}                 | ${true}
+    ${'@latest'}                | ${true}
+    ${'@stable'}                | ${true}
+  `('isValid("$version") === $expected', ({ version, expected }) => {
+    expect(versioning.isValid(version)).toBe(expected);
+  });
+
+  test.each`
+    version         | major   | minor   | patch
+    ${'17'}         | ${17}   | ${0}    | ${0}
+    ${'17.2'}       | ${17}   | ${2}    | ${0}
+    ${'17.2.3a1'}   | ${17}   | ${2}    | ${3}
+    ${'17.2.3-foo'} | ${17}   | ${2}    | ${3}
+    ${'17.2.3+m1'}  | ${17}   | ${2}    | ${3}
+    ${'@17'}        | ${17}   | ${null} | ${null}
+    ${'@17.2'}      | ${17}   | ${2}    | ${null}
+    ${'@stable'}    | ${null} | ${null} | ${null}
+  `(
+    'getMajor, getMinor, getPatch for "$version"',
+    ({ version, major, minor, patch }) => {
+      expect(versioning.getMajor(version)).toBe(major);
+      expect(versioning.getMinor(version)).toBe(minor);
+      expect(versioning.getPatch(version)).toBe(patch);
+    }
+  );
+
+  test.each`
+    version       | other        | expected
+    ${'1'}        | ${'1.2'}     | ${false}
+    ${'@1'}       | ${'@1.2'}    | ${false}
+    ${'@1.2'}     | ${'@1.2'}    | ${true}
+    ${'@1.2'}     | ${'@1.3'}    | ${false}
+    ${'@1.2.3'}   | ${'@1.2'}    | ${false}
+    ${'@1.2.3_4'} | ${'@1.2.3'}  | ${false}
+    ${'@latest'}  | ${'@1'}      | ${false}
+    ${'@stable'}  | ${'@stable'} | ${true}
+    ${'stable'}   | ${'stable'}  | ${true}
+  `(
+    'equals("$version", "$other") === $expected',
+    ({ version, other, expected }) => {
+      expect(versioning.equals(version, other)).toBe(expected);
+    }
+  );
+
+  test.each`
+    version      | other        | expected
+    ${'@1'}      | ${'@1.2'}    | ${false}
+    ${'@1.2'}    | ${'@1.2'}    | ${true}
+    ${'@1.2.3'}  | ${'@1.2'}    | ${false}
+    ${'@latest'} | ${'@1'}      | ${false}
+    ${'@stable'} | ${'@stable'} | ${true}
+  `(
+    'matches("$version", "$other") === $expected',
+    ({ version, other, expected }) => {
+      expect(versioning.matches(version, other)).toBe(expected);
+    }
+  );
+
+  test.each`
+    version      | other        | expected
+    ${'@1'}      | ${'@1.2'}    | ${true}
+    ${'@1.2'}    | ${'@1.2'}    | ${false}
+    ${'@1.2'}    | ${'@1.3'}    | ${false}
+    ${'@1.2.3'}  | ${'@1.2'}    | ${false}
+    ${'1.2.3'}   | ${'@latest'} | ${true}
+    ${'@latest'} | ${'@1'}      | ${false}
+    ${'@stable'} | ${'@latest'} | ${true}
+    ${'@latest'} | ${'@stable'} | ${false}
+  `(
+    'isGreaterThan("$version", "$other") === $expected',
+    ({ version, other, expected }) => {
+      expect(versioning.isGreaterThan(version, other)).toBe(expected);
+    }
+  );
+
+  test.each`
+    version      | other        | expected
+    ${'@1'}      | ${'@1.2'}    | ${false}
+    ${'@1.2'}    | ${'@1.2'}    | ${false}
+    ${'@1.2.3'}  | ${'@1.2'}    | ${true}
+    ${'@latest'} | ${'@1'}      | ${true}
+    ${'@stable'} | ${'@latest'} | ${false}
+    ${'@latest'} | ${'@stable'} | ${true}
+  `(
+    'isLessThanRange("$version", "$other") === $expected',
+    ({ version, other, expected }) => {
+      expect(versioning.isLessThanRange(version, other)).toBe(expected);
+    }
+  );
+
+  it('getSatisfyingVersion', () => {
+    expect(versioning.getSatisfyingVersion(['@1.1.1', '1.2.3'], '1.2.3')).toBe(
+      '1.2.3'
+    );
+    expect(
+      versioning.getSatisfyingVersion(
+        ['1.1.1', '@2.2.1', '2.2.2', '3.3.3'],
+        '2.2.2'
+      )
+    ).toBe('2.2.2');
+    expect(
+      versioning.getSatisfyingVersion(
+        ['1.1.1', '@1.3.3', '2.2.2', '3.3.3'],
+        '1.2.3'
+      )
+    ).toBeNull();
+  });
+
+  it('minSatisfyingVersion', () => {
+    expect(versioning.minSatisfyingVersion(['@1.1.1', '1.2.3'], '1.2.3')).toBe(
+      '1.2.3'
+    );
+    expect(
+      versioning.minSatisfyingVersion(
+        ['1.1.1', '@1.2.3', '2.2.2', '3.3.3'],
+        '2.2.2'
+      )
+    ).toBe('2.2.2');
+    expect(
+      versioning.minSatisfyingVersion(
+        ['1.1.1', '@1.2.2', '2.2.2', '3.3.3'],
+        '1.2.3'
+      )
+    ).toBeNull();
+  });
+
+  describe('sortVersions', () => {
+    it('sorts versions in an ascending order', () => {
+      expect(
+        [
+          '@1',
+          '1.1',
+          '1.2',
+          '1.2.3',
+          '1.3',
+          '@1.2',
+          '@2',
+          '2',
+          '2.1',
+          '@stable',
+          '@latest',
+        ].sort((a, b) => versioning.sortVersions(a, b))
+      ).toEqual([
+        '@latest',
+        '@stable',
+        '1.1',
+        '1.2',
+        '1.2.3',
+        '@1.2',
+        '1.3',
+        '@1',
+        '2',
+        '2.1',
+        '@2',
+      ]);
+    });
+  });
+});
diff --git a/lib/modules/versioning/hermit/index.ts b/lib/modules/versioning/hermit/index.ts
new file mode 100644
index 0000000000..ba23ece793
--- /dev/null
+++ b/lib/modules/versioning/hermit/index.ts
@@ -0,0 +1,136 @@
+import { RegExpVersion, RegExpVersioningApi } from '../regex';
+import type { VersioningApiConstructor } from '../types';
+
+export const id = 'hermit';
+export const displayName = 'Hermit';
+export const urls = [
+  'https://cashapp.github.io/hermit/packaging/reference/#versions',
+];
+export const supportsRanges = false;
+
+export class HermitVersioning extends RegExpVersioningApi {
+  static versionRegex =
+    '^(?<major>\\d+)(\\.(?<minor>\\d+))?(\\.(?<patch>\\d+))?(_(?<build>\\d+))?([-]?(?<prerelease>[^.+][^+]*))?([+](?<compatibility>[^.-][^+]*))?$';
+
+  public constructor() {
+    super(HermitVersioning.versionRegex);
+  }
+
+  private _isValid(version: string): boolean {
+    return super._parse(version) !== null;
+  }
+
+  protected override _parse(version: string): RegExpVersion | null {
+    const parsed = super._parse(version);
+    if (parsed) {
+      return parsed;
+    }
+    const channelVer = HermitVersioning._getChannel(version);
+
+    const groups = this._config?.exec(channelVer)?.groups;
+
+    if (!groups) {
+      return null;
+    }
+
+    const { major, minor, patch, build, prerelease, compatibility } = groups;
+    const release = [];
+
+    if (major) {
+      release.push(Number.parseInt(major, 10));
+    }
+
+    if (minor) {
+      release.push(Number.parseInt(minor, 10));
+    }
+    if (patch) {
+      release.push(Number.parseInt(patch, 10));
+    }
+    if (build) {
+      release.push(Number.parseInt(build, 10));
+    }
+
+    return {
+      release,
+      prerelease: prerelease,
+      compatibility: compatibility,
+    };
+  }
+
+  private static _isChannel(version: string): boolean {
+    return version.startsWith('@');
+  }
+
+  private static _getChannel(version: string): string {
+    return version.substring(1);
+  }
+
+  override isStable(version: string): boolean {
+    if (this._isValid(version)) {
+      return super.isStable(version);
+    }
+
+    // channel and the rest should be considered unstable version
+    // as channels are changing values
+    return false;
+  }
+
+  override isValid(version: string): boolean {
+    return this._isValid(version) || HermitVersioning._isChannel(version);
+  }
+
+  override isLessThanRange(version: string, range: string): boolean {
+    return this._compare(version, range) < 0;
+  }
+
+  protected override _compare(version: string, other: string): number {
+    if (this._isValid(version) && this._isValid(other)) {
+      return super._compare(version, other);
+    }
+
+    const parsedVersion = this._parse(version);
+    const parsedOther = this._parse(other);
+
+    if (parsedVersion === null || parsedOther === null) {
+      if (parsedVersion === null && parsedOther === null) {
+        return version.localeCompare(other);
+      }
+      return parsedVersion === null ? -1 : 1;
+    }
+
+    const versionReleases = parsedVersion.release;
+    const otherReleases = parsedOther.release;
+
+    const maxLength =
+      versionReleases.length > otherReleases.length
+        ? versionReleases.length
+        : otherReleases.length;
+
+    for (let i = 0; i < maxLength; i++) {
+      const verVal = versionReleases[i];
+      const otherVal = otherReleases[i];
+
+      if (
+        verVal !== undefined &&
+        otherVal !== undefined &&
+        verVal !== otherVal
+      ) {
+        return verVal - otherVal;
+      } else if (verVal === undefined) {
+        return 1;
+      } else if (otherVal === undefined) {
+        return -1;
+      }
+    }
+
+    return 0;
+  }
+
+  override matches(version: string, range: string): boolean {
+    return this.equals(version, range);
+  }
+}
+
+export const api: VersioningApiConstructor = HermitVersioning;
+
+export default api;
diff --git a/lib/modules/versioning/hermit/readme.md b/lib/modules/versioning/hermit/readme.md
new file mode 100644
index 0000000000..44340dfd2f
--- /dev/null
+++ b/lib/modules/versioning/hermit/readme.md
@@ -0,0 +1,13 @@
+Hermit versioning is a mix of `version` and `channel`.
+
+**Version**
+
+Hermit's package version comes from the packge's original git tag. The version is
+an extension to semver, with an extra build number to accomondate package
+versions from OpenJDK, which has a value `15.0.1_9`.
+
+**Channel**
+
+[Channel](https://cashapp.github.io/hermit/packaging/reference/#channels) could be hermit generated or user defined.
+Channel is considered unstable version and normally won't upgrade.
+If you would like to get out of Channel, you could replace the Channel with a given version number and let it managed by Renovate ongoing.
diff --git a/lib/modules/versioning/regex/index.ts b/lib/modules/versioning/regex/index.ts
index c06a413a1b..e865fa62d2 100644
--- a/lib/modules/versioning/regex/index.ts
+++ b/lib/modules/versioning/regex/index.ts
@@ -39,7 +39,7 @@ export class RegExpVersioningApi extends GenericVersioningApi<RegExpVersion> {
   //   RegExp('^(?<major>\\d+)\\.(?<minor>\\d+)\\.(?<patch>\\d+)(?<prerelease>[^.-]+)?(-(?<compatibility>.*))?$');
   // * matches the versioning approach used by the Bitnami images on DockerHub:
   //   RegExp('^(?<major>\\d+)\\.(?<minor>\\d+)\\.(?<patch>\\d+)(:?-(?<compatibility>.*-r)(?<build>\\d+))?$');
-  private _config: RegExp | null = null;
+  protected readonly _config: RegExp;
 
   constructor(_new_config: string | undefined) {
     super();
-- 
GitLab