From 443b22b0ae74b43111e3e79e900cf369756a1344 Mon Sep 17 00:00:00 2001
From: Sebastian Poxhofer <secustor@users.noreply.github.com>
Date: Wed, 1 Sep 2021 11:36:38 +0200
Subject: [PATCH] feat(gitlab-release): implement datasource (#11226)

Co-authored-by: Nejc Habjan <hab.nejc@gmail.com>
Co-authored-by: Rhys Arkins <rhys@arkins.net>
Co-authored-by: Michael Kriese <michael.kriese@visualon.de>
---
 lib/datasource/api.ts                         |  2 +
 .../__snapshots__/index.spec.ts.snap          | 43 ++++++++++++++
 lib/datasource/gitlab-releases/index.spec.ts  | 58 +++++++++++++++++++
 lib/datasource/gitlab-releases/index.ts       | 54 +++++++++++++++++
 lib/datasource/gitlab-releases/types.ts       |  5 ++
 lib/datasource/gitlab-tags/index.ts           |  4 +-
 lib/types/platform/gitlab/index.spec.ts       | 25 ++++++++
 lib/types/platform/gitlab/index.ts            |  7 +++
 .../http/__snapshots__/gitlab.spec.ts.snap    | 15 +++++
 lib/util/http/auth.ts                         |  8 +--
 lib/util/http/gitlab.spec.ts                  | 33 +++++++++--
 lib/util/http/gitlab.ts                       |  7 ++-
 12 files changed, 247 insertions(+), 14 deletions(-)
 create mode 100644 lib/datasource/gitlab-releases/__snapshots__/index.spec.ts.snap
 create mode 100644 lib/datasource/gitlab-releases/index.spec.ts
 create mode 100644 lib/datasource/gitlab-releases/index.ts
 create mode 100644 lib/datasource/gitlab-releases/types.ts
 create mode 100644 lib/types/platform/gitlab/index.spec.ts

diff --git a/lib/datasource/api.ts b/lib/datasource/api.ts
index b6c3d51137..356af4bc8a 100644
--- a/lib/datasource/api.ts
+++ b/lib/datasource/api.ts
@@ -11,6 +11,7 @@ import * as gitRefs from './git-refs';
 import * as gitTags from './git-tags';
 import * as githubReleases from './github-releases';
 import * as githubTags from './github-tags';
+import { GitlabReleasesDatasource } from './gitlab-releases';
 import * as gitlabTags from './gitlab-tags';
 import * as go from './go';
 import { GradleVersionDatasource } from './gradle-version';
@@ -50,6 +51,7 @@ api.set('git-tags', gitTags);
 api.set('github-releases', githubReleases);
 api.set('github-tags', githubTags);
 api.set('gitlab-tags', gitlabTags);
+api.set(GitlabReleasesDatasource.id, new GitlabReleasesDatasource());
 api.set('go', go);
 api.set('gradle-version', new GradleVersionDatasource());
 api.set('helm', new HelmDatasource());
diff --git a/lib/datasource/gitlab-releases/__snapshots__/index.spec.ts.snap b/lib/datasource/gitlab-releases/__snapshots__/index.spec.ts.snap
new file mode 100644
index 0000000000..f39b80f4c8
--- /dev/null
+++ b/lib/datasource/gitlab-releases/__snapshots__/index.spec.ts.snap
@@ -0,0 +1,43 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`datasource/gitlab-releases/index getReleases returns releases from custom registry 1`] = `
+Object {
+  "registryUrl": "https://gitlab.company.com",
+  "releases": Array [
+    Object {
+      "gitRef": "v1.0.0",
+      "registryUrl": "https://gitlab.company.com",
+      "releaseTimestamp": "2021-01-01T00:00:00.000Z",
+      "version": "v1.0.0",
+    },
+    Object {
+      "gitRef": "v1.1.0",
+      "registryUrl": "https://gitlab.company.com",
+      "releaseTimestamp": "2021-03-01T00:00:00.000Z",
+      "version": "v1.1.0",
+    },
+  ],
+  "sourceUrl": "https://gitlab.company.com/some/dep2",
+}
+`;
+
+exports[`datasource/gitlab-releases/index getReleases returns releases from default registry 1`] = `
+Object {
+  "registryUrl": "https://gitlab.com",
+  "releases": Array [
+    Object {
+      "gitRef": "v1.0.0",
+      "registryUrl": "https://gitlab.com",
+      "releaseTimestamp": "2021-01-01T00:00:00.000Z",
+      "version": "v1.0.0",
+    },
+    Object {
+      "gitRef": "v1.1.0",
+      "registryUrl": "https://gitlab.com",
+      "releaseTimestamp": "2021-03-01T00:00:00.000Z",
+      "version": "v1.1.0",
+    },
+  ],
+  "sourceUrl": "https://gitlab.com/some/dep2",
+}
+`;
diff --git a/lib/datasource/gitlab-releases/index.spec.ts b/lib/datasource/gitlab-releases/index.spec.ts
new file mode 100644
index 0000000000..e4a438d007
--- /dev/null
+++ b/lib/datasource/gitlab-releases/index.spec.ts
@@ -0,0 +1,58 @@
+import { getPkgReleases } from '..';
+import * as httpMock from '../../../test/http-mock';
+import { GitlabReleasesDatasource } from '.';
+
+describe('datasource/gitlab-releases/index', () => {
+  describe('getReleases', () => {
+    const body = [
+      {
+        tag_name: 'v1.0.0',
+        released_at: '2021-01-01T00:00:00.000Z',
+      },
+      {
+        tag_name: 'v1.1.0',
+        released_at: '2021-03-01T00:00:00.000Z',
+      },
+    ];
+
+    it('returns releases from custom registry', async () => {
+      httpMock
+        .scope('https://gitlab.company.com')
+        .get('/api/v4/projects/some%2Fdep2/releases')
+        .reply(200, body);
+      const res = await getPkgReleases({
+        datasource: GitlabReleasesDatasource.id,
+        registryUrls: ['https://gitlab.company.com'],
+        depName: 'some/dep2',
+      });
+      expect(res).toMatchSnapshot();
+      expect(res.releases).toHaveLength(2);
+    });
+
+    it('returns releases from default registry', async () => {
+      httpMock
+        .scope('https://gitlab.com')
+        .get('/api/v4/projects/some%2Fdep2/releases')
+        .reply(200, body);
+      const res = await getPkgReleases({
+        datasource: GitlabReleasesDatasource.id,
+        depName: 'some/dep2',
+      });
+      expect(res).toMatchSnapshot();
+      expect(res.releases).toHaveLength(2);
+    });
+
+    it('return null if not found', async () => {
+      httpMock
+        .scope('https://gitlab.com')
+        .get('/api/v4/projects/some%2Fdep2/releases')
+        .reply(404);
+      expect(
+        await getPkgReleases({
+          datasource: GitlabReleasesDatasource.id,
+          depName: 'some/dep2',
+        })
+      ).toBeNull();
+    });
+  });
+});
diff --git a/lib/datasource/gitlab-releases/index.ts b/lib/datasource/gitlab-releases/index.ts
new file mode 100644
index 0000000000..6a4cec10bf
--- /dev/null
+++ b/lib/datasource/gitlab-releases/index.ts
@@ -0,0 +1,54 @@
+import { cache } from '../../util/cache/package/decorator';
+import { GitlabHttp } from '../../util/http/gitlab';
+import { Datasource } from '../datasource';
+import type { GetReleasesConfig, Release, ReleaseResult } from '../types';
+import type { GitlabRelease } from './types';
+
+export class GitlabReleasesDatasource extends Datasource {
+  static readonly id = 'gitlab-releases';
+
+  override readonly defaultRegistryUrls = ['https://gitlab.com'];
+
+  static readonly registryStrategy = 'first';
+
+  constructor() {
+    super(GitlabReleasesDatasource.id);
+    this.http = new GitlabHttp(GitlabReleasesDatasource.id);
+  }
+
+  @cache({
+    namespace: `datasource-${GitlabReleasesDatasource.id}`,
+    key: ({ registryUrl, lookupName }: GetReleasesConfig) =>
+      `${registryUrl}/${lookupName}`,
+  })
+  async getReleases({
+    registryUrl,
+    lookupName,
+  }: GetReleasesConfig): Promise<ReleaseResult | null> {
+    const urlEncodedRepo = encodeURIComponent(lookupName);
+    const apiUrl = `${registryUrl}/api/v4/projects/${urlEncodedRepo}/releases`;
+
+    try {
+      const gitlabReleasesResponse = (
+        await this.http.getJson<GitlabRelease[]>(apiUrl)
+      ).body;
+
+      return {
+        sourceUrl: `${registryUrl}/${lookupName}`,
+        releases: gitlabReleasesResponse.map(({ tag_name, released_at }) => {
+          const release: Release = {
+            registryUrl,
+            gitRef: tag_name,
+            version: tag_name,
+            releaseTimestamp: released_at,
+          };
+          return release;
+        }),
+      };
+    } catch (e) {
+      this.handleGenericErrors(e);
+    }
+    /* istanbul ignore next */
+    return null;
+  }
+}
diff --git a/lib/datasource/gitlab-releases/types.ts b/lib/datasource/gitlab-releases/types.ts
new file mode 100644
index 0000000000..d6b4cab4fb
--- /dev/null
+++ b/lib/datasource/gitlab-releases/types.ts
@@ -0,0 +1,5 @@
+export interface GitlabRelease {
+  name: string;
+  tag_name: string;
+  released_at: string;
+}
diff --git a/lib/datasource/gitlab-tags/index.ts b/lib/datasource/gitlab-tags/index.ts
index bf3355c07b..fde3a5f869 100644
--- a/lib/datasource/gitlab-tags/index.ts
+++ b/lib/datasource/gitlab-tags/index.ts
@@ -4,9 +4,9 @@ import { joinUrlParts } from '../../util/url';
 import type { GetReleasesConfig, ReleaseResult } from '../types';
 import type { GitlabTag } from './types';
 
-const gitlabApi = new GitlabHttp();
-
 export const id = 'gitlab-tags';
+const gitlabApi = new GitlabHttp(id);
+
 export const customRegistrySupport = true;
 export const defaultRegistryUrls = ['https://gitlab.com'];
 export const registryStrategy = 'first';
diff --git a/lib/types/platform/gitlab/index.spec.ts b/lib/types/platform/gitlab/index.spec.ts
new file mode 100644
index 0000000000..945f2e4476
--- /dev/null
+++ b/lib/types/platform/gitlab/index.spec.ts
@@ -0,0 +1,25 @@
+import {
+  PLATFORM_TYPE_GITHUB,
+  PLATFORM_TYPE_GITLAB,
+} from '../../../constants/platforms';
+import { GitlabReleasesDatasource } from '../../../datasource/gitlab-releases';
+import { id as GL_TAGS_DS } from '../../../datasource/gitlab-tags';
+import { GITLAB_API_USING_HOST_TYPES } from './index';
+
+describe('types/platform/gitlab/index', () => {
+  it('should be part of the GITLAB_API_USING_HOST_TYPES', () => {
+    expect(GITLAB_API_USING_HOST_TYPES.includes(GL_TAGS_DS)).toBeTrue();
+    expect(
+      GITLAB_API_USING_HOST_TYPES.includes(GitlabReleasesDatasource.id)
+    ).toBeTrue();
+    expect(
+      GITLAB_API_USING_HOST_TYPES.includes(PLATFORM_TYPE_GITLAB)
+    ).toBeTrue();
+  });
+
+  it('should be not part of the GITLAB_API_USING_HOST_TYPES ', () => {
+    expect(
+      GITLAB_API_USING_HOST_TYPES.includes(PLATFORM_TYPE_GITHUB)
+    ).toBeFalse();
+  });
+});
diff --git a/lib/types/platform/gitlab/index.ts b/lib/types/platform/gitlab/index.ts
index a67df13e1a..999f9149b1 100644
--- a/lib/types/platform/gitlab/index.ts
+++ b/lib/types/platform/gitlab/index.ts
@@ -1,3 +1,4 @@
+import { PLATFORM_TYPE_GITLAB } from '../../../constants/platforms';
 import { GitTreeNode } from '../../git';
 
 export type GitLabBranch = {
@@ -12,3 +13,9 @@ export type GitlabTreeNode = {
   id: string;
   name: string;
 } & GitTreeNode;
+
+export const GITLAB_API_USING_HOST_TYPES = [
+  PLATFORM_TYPE_GITLAB,
+  'gitlab-releases',
+  'gitlab-tags',
+];
diff --git a/lib/util/http/__snapshots__/gitlab.spec.ts.snap b/lib/util/http/__snapshots__/gitlab.spec.ts.snap
index 8b1f605d78..56e7c378c3 100644
--- a/lib/util/http/__snapshots__/gitlab.spec.ts.snap
+++ b/lib/util/http/__snapshots__/gitlab.spec.ts.snap
@@ -107,3 +107,18 @@ Array [
   },
 ]
 `;
+
+exports[`util/http/gitlab supports different datasources 1`] = `
+Array [
+  Object {
+    "headers": Object {
+      "accept-encoding": "gzip, deflate, br",
+      "authorization": "Bearer def",
+      "host": "gitlab.com",
+      "user-agent": "RenovateBot/0.0.0-semantic-release (https://github.com/renovatebot/renovate)",
+    },
+    "method": "GET",
+    "url": "https://gitlab.com/api/v4/some-url",
+  },
+]
+`;
diff --git a/lib/util/http/auth.ts b/lib/util/http/auth.ts
index 0c14ca7092..b3a5383dfc 100644
--- a/lib/util/http/auth.ts
+++ b/lib/util/http/auth.ts
@@ -1,10 +1,8 @@
 import is from '@sindresorhus/is';
 import { NormalizedOptions } from 'got';
-import {
-  PLATFORM_TYPE_GITEA,
-  PLATFORM_TYPE_GITLAB,
-} from '../../constants/platforms';
+import { PLATFORM_TYPE_GITEA } from '../../constants/platforms';
 import { GITHUB_API_USING_HOST_TYPES } from '../../types';
+import { GITLAB_API_USING_HOST_TYPES } from '../../types/platform/gitlab';
 import { GotOptions } from './types';
 
 export function applyAuthorization(inOptions: GotOptions): GotOptions {
@@ -29,7 +27,7 @@ export function applyAuthorization(inOptions: GotOptions): GotOptions {
           );
         }
       }
-    } else if (options.hostType === PLATFORM_TYPE_GITLAB) {
+    } else if (GITLAB_API_USING_HOST_TYPES.includes(options.hostType)) {
       // GitLab versions earlier than 12.2 only support authentication with
       // a personal access token, which is 20 characters long.
       if (options.token.length === 20) {
diff --git a/lib/util/http/gitlab.spec.ts b/lib/util/http/gitlab.spec.ts
index acd7c6d989..13f6a3962f 100644
--- a/lib/util/http/gitlab.spec.ts
+++ b/lib/util/http/gitlab.spec.ts
@@ -1,14 +1,10 @@
 import * as httpMock from '../../../test/http-mock';
 import { EXTERNAL_HOST_ERROR } from '../../constants/error-messages';
 import { PLATFORM_TYPE_GITLAB } from '../../constants/platforms';
+import { GitlabReleasesDatasource } from '../../datasource/gitlab-releases';
 import * as hostRules from '../host-rules';
 import { GitlabHttp, setBaseUrl } from './gitlab';
 
-hostRules.add({
-  hostType: PLATFORM_TYPE_GITLAB,
-  token: 'abc123',
-});
-
 const gitlabApiHost = 'https://gitlab.com';
 const selfHostedUrl = 'http://mycompany.com/gitlab';
 
@@ -19,10 +15,17 @@ describe('util/http/gitlab', () => {
     gitlabApi = new GitlabHttp();
     setBaseUrl(`${gitlabApiHost}/api/v4/`);
     delete process.env.GITLAB_IGNORE_REPO_URL;
+
+    hostRules.add({
+      hostType: PLATFORM_TYPE_GITLAB,
+      token: 'abc123',
+    });
   });
 
   afterEach(() => {
     jest.resetAllMocks();
+
+    hostRules.clear();
   });
 
   it('paginates', async () => {
@@ -68,6 +71,26 @@ describe('util/http/gitlab', () => {
     expect(trace).toHaveLength(3);
     expect(trace).toMatchSnapshot();
   });
+
+  it('supports different datasources', async () => {
+    const gitlabApiDatasource = new GitlabHttp(GitlabReleasesDatasource.id);
+    hostRules.add({ hostType: PLATFORM_TYPE_GITLAB, token: 'abc' });
+    hostRules.add({
+      hostType: GitlabReleasesDatasource.id,
+      token: 'def',
+    });
+    httpMock
+      .scope(gitlabApiHost, { reqheaders: { authorization: 'Bearer def' } })
+      .get('/api/v4/some-url')
+      .reply(200);
+    const response = await gitlabApiDatasource.get('/some-url');
+    expect(response).not.toBeNull();
+
+    const trace = httpMock.getTrace();
+    expect(trace).toHaveLength(1);
+    expect(trace).toMatchSnapshot();
+  });
+
   it('attempts to paginate', async () => {
     httpMock.scope(gitlabApiHost).get('/api/v4/some-url').reply(200, ['a'], {
       link: '<https://gitlab.com/api/v4/some-url&page=3>; rel="last"',
diff --git a/lib/util/http/gitlab.ts b/lib/util/http/gitlab.ts
index a9ca70f00c..18eacdd224 100644
--- a/lib/util/http/gitlab.ts
+++ b/lib/util/http/gitlab.ts
@@ -20,8 +20,11 @@ export interface GitlabHttpOptions extends InternalHttpOptions {
 }
 
 export class GitlabHttp extends Http<GitlabHttpOptions, GitlabHttpOptions> {
-  constructor(options?: GitlabHttpOptions) {
-    super(PLATFORM_TYPE_GITLAB, options);
+  constructor(
+    type: string = PLATFORM_TYPE_GITLAB,
+    options?: GitlabHttpOptions
+  ) {
+    super(type, options);
   }
 
   protected override async request<T>(
-- 
GitLab