From 953ef18e8790954919c30c0ea8adb08157114fee Mon Sep 17 00:00:00 2001
From: Dominic Seitz <dominic.seitz@gmx.de>
Date: Thu, 9 Jun 2022 15:48:40 +0200
Subject: [PATCH] feat(gitea): Support gitUrl (#14947)

* feat: Support gitUrl on gitea platform

* refactor: use query token instead of auth header

* refactor: debug message style

* refactor: use url property query

* refactor: use basic http auth for gitea

* test: add gitea tests for gitUrl property

* refactor: capitalising abbreviations

Co-authored-by: HonkingGoose <34918129+HonkingGoose@users.noreply.github.com>

* refactor: move getRepoUrl to utils

* fix: add missing property ssh_url to fix linting

* fix: utils strict mode issues

* Merge branch 'main' into main

* refactor: use endpoint without api path slicing

* refactor: use different null check

* refactor: make urls optional

Co-authored-by: Michael Kriese <michael.kriese@visualon.de>

* test: prettier fix

* test: update test

* refactor: throw error if clone_url is missing

* test: refactor empty clone_url test

* refactor: change empty clone_url logic

* refactor: change imports

Co-authored-by: Michael Kriese <michael.kriese@visualon.de>

* test: add token tests

* refactor: replace deprecated url module

* refactor: add null checks

* test: add tests for null checks

* test: use host rule implementation instead of mock

* refactor: remove explicit typing

Co-authored-by: Michael Kriese <michael.kriese@visualon.de>

* test: update mocking

Co-authored-by: Michael Kriese <michael.kriese@visualon.de>

* test: change dynamic imports

* Update lib/modules/platform/gitea/utils.ts

Co-authored-by: HonkingGoose <34918129+HonkingGoose@users.noreply.github.com>
Co-authored-by: Michael Kriese <michael.kriese@visualon.de>
Co-authored-by: Rhys Arkins <rhys@arkins.net>
---
 .../platform/gitea/gitea-helper.spec.ts       |   1 +
 lib/modules/platform/gitea/gitea-helper.ts    |   3 +-
 lib/modules/platform/gitea/index.spec.ts      | 168 +++++++++++++++++-
 lib/modules/platform/gitea/index.ts           |  15 +-
 lib/modules/platform/gitea/utils.spec.ts      |  27 ++-
 lib/modules/platform/gitea/utils.ts           |  54 ++++++
 6 files changed, 252 insertions(+), 16 deletions(-)

diff --git a/lib/modules/platform/gitea/gitea-helper.spec.ts b/lib/modules/platform/gitea/gitea-helper.spec.ts
index 7d82268dc6..d67aed8b1a 100644
--- a/lib/modules/platform/gitea/gitea-helper.spec.ts
+++ b/lib/modules/platform/gitea/gitea-helper.spec.ts
@@ -30,6 +30,7 @@ describe('modules/platform/gitea/gitea-helper', () => {
     allow_merge_commits: true,
     allow_squash_merge: true,
     clone_url: 'https://gitea.renovatebot.com/some/repo.git',
+    ssh_url: 'git@gitea.renovatebot.com/some/repo.git',
     default_branch: 'master',
     full_name: 'some/repo',
     archived: false,
diff --git a/lib/modules/platform/gitea/gitea-helper.ts b/lib/modules/platform/gitea/gitea-helper.ts
index 1a0b5e3041..ec257948d5 100644
--- a/lib/modules/platform/gitea/gitea-helper.ts
+++ b/lib/modules/platform/gitea/gitea-helper.ts
@@ -64,7 +64,8 @@ export interface Repo {
   allow_rebase_explicit: boolean;
   allow_squash_merge: boolean;
   archived: boolean;
-  clone_url: string;
+  clone_url?: string;
+  ssh_url?: string;
   default_branch: string;
   empty: boolean;
   fork: boolean;
diff --git a/lib/modules/platform/gitea/index.spec.ts b/lib/modules/platform/gitea/index.spec.ts
index c2edace2c2..0e8582b100 100644
--- a/lib/modules/platform/gitea/index.spec.ts
+++ b/lib/modules/platform/gitea/index.spec.ts
@@ -5,8 +5,10 @@ import type {
   RepoParams,
   RepoResult,
 } from '..';
-import { partial } from '../../../../test/util';
+import { mocked, partial } from '../../../../test/util';
+import { PlatformId } from '../../../constants';
 import {
+  CONFIG_GIT_URL_UNAVAILABLE,
   REPOSITORY_ACCESS_FORBIDDEN,
   REPOSITORY_ARCHIVED,
   REPOSITORY_BLOCKED,
@@ -31,6 +33,7 @@ describe('modules/platform/gitea/index', () => {
   let helper: jest.Mocked<typeof import('./gitea-helper')>;
   let logger: jest.Mocked<typeof _logger>;
   let gitvcs: jest.Mocked<typeof _git>;
+  let hostRules: jest.Mocked<typeof import('../../../util/host-rules')>;
 
   const mockCommitHash = '0d9c7726c3d628b7e28af234595cfd20febdbf8e';
 
@@ -44,6 +47,7 @@ describe('modules/platform/gitea/index', () => {
   const mockRepo = partial<ght.Repo>({
     allow_rebase: true,
     clone_url: 'https://gitea.renovatebot.com/some/repo.git',
+    ssh_url: 'git@gitea.renovatebot.com/some/repo.git',
     default_branch: 'master',
     full_name: 'some/repo',
     permissions: {
@@ -172,11 +176,13 @@ describe('modules/platform/gitea/index', () => {
     jest.mock('../../../logger');
 
     gitea = await import('.');
-    helper = (await import('./gitea-helper')) as any;
-    logger = (await import('../../../logger')).logger as any;
+    helper = mocked(await import('./gitea-helper'));
+    logger = mocked((await import('../../../logger')).logger);
     gitvcs = require('../../../util/git');
     gitvcs.isBranchStale.mockResolvedValue(false);
     gitvcs.getBranchCommit.mockReturnValue(mockCommitHash);
+    hostRules = mocked(await import('../../../util/host-rules'));
+    hostRules.clear();
 
     setBaseUrl('https://gitea.renovatebot.com/');
   });
@@ -338,6 +344,162 @@ describe('modules/platform/gitea/index', () => {
         })
       ).toMatchSnapshot();
     });
+
+    it('should use clone_url of repo if gitUrl is not specified', async () => {
+      expect.assertions(1);
+
+      helper.getRepo.mockResolvedValueOnce(mockRepo);
+      const repoCfg: RepoParams = {
+        repository: mockRepo.full_name,
+      };
+      await gitea.initRepo(repoCfg);
+
+      expect(gitvcs.initRepo).toHaveBeenCalledWith(
+        expect.objectContaining({ url: mockRepo.clone_url })
+      );
+    });
+
+    it('should use clone_url of repo if gitUrl has value default', async () => {
+      expect.assertions(1);
+
+      helper.getRepo.mockResolvedValueOnce(mockRepo);
+      const repoCfg: RepoParams = {
+        repository: mockRepo.full_name,
+        gitUrl: 'default',
+      };
+      await gitea.initRepo(repoCfg);
+
+      expect(gitvcs.initRepo).toHaveBeenCalledWith(
+        expect.objectContaining({ url: mockRepo.clone_url })
+      );
+    });
+
+    it('should use ssh_url of repo if gitUrl has value ssh', async () => {
+      expect.assertions(1);
+
+      helper.getRepo.mockResolvedValueOnce(mockRepo);
+      const repoCfg: RepoParams = {
+        repository: mockRepo.full_name,
+        gitUrl: 'ssh',
+      };
+      await gitea.initRepo(repoCfg);
+
+      expect(gitvcs.initRepo).toHaveBeenCalledWith(
+        expect.objectContaining({ url: mockRepo.ssh_url })
+      );
+    });
+
+    it('should abort when gitUrl has value ssh but ssh_url is empty', async () => {
+      expect.assertions(1);
+
+      helper.getRepo.mockResolvedValueOnce({ ...mockRepo, ssh_url: undefined });
+      const repoCfg: RepoParams = {
+        repository: mockRepo.full_name,
+        gitUrl: 'ssh',
+      };
+
+      await expect(gitea.initRepo(repoCfg)).rejects.toThrow(
+        CONFIG_GIT_URL_UNAVAILABLE
+      );
+    });
+
+    it('should use generated url of repo if gitUrl has value endpoint', async () => {
+      expect.assertions(1);
+
+      helper.getRepo.mockResolvedValueOnce(mockRepo);
+      const repoCfg: RepoParams = {
+        repository: mockRepo.full_name,
+        gitUrl: 'endpoint',
+      };
+      await gitea.initRepo(repoCfg);
+
+      expect(gitvcs.initRepo).toHaveBeenCalledWith(
+        expect.objectContaining({
+          url: `https://gitea.com/${mockRepo.full_name}.git`,
+        })
+      );
+    });
+
+    it('should abort when clone_url is empty', async () => {
+      expect.assertions(1);
+
+      helper.getRepo.mockResolvedValueOnce({
+        ...mockRepo,
+        clone_url: undefined,
+      });
+      const repoCfg: RepoParams = {
+        repository: mockRepo.full_name,
+      };
+
+      await expect(gitea.initRepo(repoCfg)).rejects.toThrow(
+        CONFIG_GIT_URL_UNAVAILABLE
+      );
+    });
+
+    it('should use given access token if gitUrl has value endpoint', async () => {
+      expect.assertions(1);
+
+      const token = 'abc';
+      hostRules.add({
+        hostType: PlatformId.Gitea,
+        matchHost: 'https://gitea.com/',
+        token,
+      });
+
+      helper.getRepo.mockResolvedValueOnce(mockRepo);
+      const repoCfg: RepoParams = {
+        repository: mockRepo.full_name,
+        gitUrl: 'endpoint',
+      };
+      await gitea.initRepo(repoCfg);
+
+      const url = new URL(`${mockRepo.clone_url}`);
+      url.username = token;
+      expect(gitvcs.initRepo).toHaveBeenCalledWith(
+        expect.objectContaining({
+          url: `https://${token}@gitea.com/${mockRepo.full_name}.git`,
+        })
+      );
+    });
+
+    it('should use given access token if gitUrl is not specified', async () => {
+      expect.assertions(1);
+
+      const token = 'abc';
+      hostRules.add({
+        hostType: PlatformId.Gitea,
+        matchHost: 'https://gitea.com/',
+        token,
+      });
+
+      helper.getRepo.mockResolvedValueOnce(mockRepo);
+      const repoCfg: RepoParams = {
+        repository: mockRepo.full_name,
+      };
+      await gitea.initRepo(repoCfg);
+
+      const url = new URL(`${mockRepo.clone_url}`);
+      url.username = token;
+      expect(gitvcs.initRepo).toHaveBeenCalledWith(
+        expect.objectContaining({ url: url.toString() })
+      );
+    });
+
+    it('should abort when clone_url is not valid', async () => {
+      expect.assertions(1);
+
+      helper.getRepo.mockResolvedValueOnce({
+        ...mockRepo,
+        clone_url: 'abc',
+      });
+      const repoCfg: RepoParams = {
+        repository: mockRepo.full_name,
+      };
+
+      await expect(gitea.initRepo(repoCfg)).rejects.toThrow(
+        CONFIG_GIT_URL_UNAVAILABLE
+      );
+    });
   });
 
   describe('setBranchStatus', () => {
diff --git a/lib/modules/platform/gitea/index.ts b/lib/modules/platform/gitea/index.ts
index 29842a7937..97e437db5e 100644
--- a/lib/modules/platform/gitea/index.ts
+++ b/lib/modules/platform/gitea/index.ts
@@ -1,4 +1,3 @@
-import URL from 'url';
 import is from '@sindresorhus/is';
 import JSON5 from 'json5';
 import semver from 'semver';
@@ -14,7 +13,6 @@ import {
 import { logger } from '../../../logger';
 import { BranchStatus, PrState, VulnerabilityAlert } from '../../../types';
 import * as git from '../../../util/git';
-import * as hostRules from '../../../util/host-rules';
 import { setBaseUrl } from '../../../util/http/gitea';
 import { sanitize } from '../../../util/sanitize';
 import { ensureTrailingSlash } from '../../../util/url';
@@ -38,7 +36,7 @@ import type {
 } from '../types';
 import { smartTruncate } from '../utils/pr-body';
 import * as helper from './gitea-helper';
-import { smartLinks, trimTrailingApiPath } from './utils';
+import { getRepoUrl, smartLinks, trimTrailingApiPath } from './utils';
 
 interface GiteaRepoConfig {
   repository: string;
@@ -240,6 +238,7 @@ const platform: Platform = {
   async initRepo({
     repository,
     cloneSubmodules,
+    gitUrl,
   }: RepoParams): Promise<RepoResult> {
     let repo: helper.Repo;
 
@@ -298,18 +297,12 @@ const platform: Platform = {
     config.defaultBranch = repo.default_branch;
     logger.debug(`${repository} default branch = ${config.defaultBranch}`);
 
-    // Find options for current host and determine Git endpoint
-    const opts = hostRules.find({
-      hostType: PlatformId.Gitea,
-      url: defaults.endpoint,
-    });
-    const gitEndpoint = URL.parse(repo.clone_url);
-    gitEndpoint.auth = opts.token ?? null;
+    const url = getRepoUrl(repo, gitUrl, defaults.endpoint);
 
     // Initialize Git storage
     await git.initRepo({
       ...config,
-      url: URL.format(gitEndpoint),
+      url,
     });
 
     // Reset cached resources
diff --git a/lib/modules/platform/gitea/utils.spec.ts b/lib/modules/platform/gitea/utils.spec.ts
index a091434e5f..f7982d4c6d 100644
--- a/lib/modules/platform/gitea/utils.spec.ts
+++ b/lib/modules/platform/gitea/utils.spec.ts
@@ -1,6 +1,22 @@
-import { trimTrailingApiPath } from './utils';
+import { partial } from '../../../../test/util';
+import { CONFIG_GIT_URL_UNAVAILABLE } from '../../../constants/error-messages';
+import type { Repo } from './gitea-helper';
+import { getRepoUrl, trimTrailingApiPath } from './utils';
 
 describe('modules/platform/gitea/utils', () => {
+  const mockRepo = partial<Repo>({
+    allow_rebase: true,
+    clone_url: 'https://gitea.renovatebot.com/some/repo.git',
+    ssh_url: 'git@gitea.renovatebot.com/some/repo.git',
+    default_branch: 'master',
+    full_name: 'some/repo',
+    permissions: {
+      pull: true,
+      push: true,
+      admin: false,
+    },
+  });
+
   it('trimTrailingApiPath', () => {
     expect(trimTrailingApiPath('https://gitea.renovatebot.com/api/v1')).toBe(
       'https://gitea.renovatebot.com/'
@@ -18,4 +34,13 @@ describe('modules/platform/gitea/utils', () => {
       trimTrailingApiPath('https://gitea.renovatebot.com/api/gitea/api/v1')
     ).toBe('https://gitea.renovatebot.com/api/gitea/');
   });
+
+  describe('getRepoUrl', () => {
+    it('should abort when endpoint is not valid', () => {
+      expect.assertions(1);
+      expect(() => getRepoUrl(mockRepo, 'endpoint', 'abc')).toThrow(
+        CONFIG_GIT_URL_UNAVAILABLE
+      );
+    });
+  });
 });
diff --git a/lib/modules/platform/gitea/utils.ts b/lib/modules/platform/gitea/utils.ts
index 927ce0a395..5a119a5c47 100644
--- a/lib/modules/platform/gitea/utils.ts
+++ b/lib/modules/platform/gitea/utils.ts
@@ -1,4 +1,11 @@
+import { PlatformId } from '../../../constants';
+import { CONFIG_GIT_URL_UNAVAILABLE } from '../../../constants/error-messages';
+import { logger } from '../../../logger';
+import * as hostRules from '../../../util/host-rules';
 import { regEx } from '../../../util/regex';
+import { parseUrl } from '../../../util/url';
+import type { GitUrlOption } from '../types';
+import type { Repo } from './gitea-helper';
 
 export function smartLinks(body: string): string {
   return body?.replace(regEx(/\]\(\.\.\/pull\//g), '](pulls/');
@@ -7,3 +14,50 @@ export function smartLinks(body: string): string {
 export function trimTrailingApiPath(url: string): string {
   return url?.replace(regEx(/api\/v1\/?$/g), '');
 }
+
+export function getRepoUrl(
+  repo: Repo,
+  gitUrl: GitUrlOption | undefined,
+  endpoint: string
+): string {
+  if (gitUrl === 'ssh') {
+    if (!repo.ssh_url) {
+      throw new Error(CONFIG_GIT_URL_UNAVAILABLE);
+    }
+    logger.debug({ url: repo.ssh_url }, `using SSH URL`);
+    return repo.ssh_url;
+  }
+
+  // Find options for current host and determine Git endpoint
+  const opts = hostRules.find({
+    hostType: PlatformId.Gitea,
+    url: endpoint,
+  });
+
+  if (gitUrl === 'endpoint') {
+    const url = parseUrl(endpoint);
+    if (!url) {
+      throw new Error(CONFIG_GIT_URL_UNAVAILABLE);
+    }
+    url.protocol = url.protocol?.slice(0, -1) ?? 'https';
+    url.username = opts.token ?? '';
+    url.pathname = `${url.pathname}${repo.full_name}.git`;
+    logger.debug(
+      { url: url.toString() },
+      'using URL based on configured endpoint'
+    );
+    return url.toString();
+  }
+
+  if (!repo.clone_url) {
+    throw new Error(CONFIG_GIT_URL_UNAVAILABLE);
+  }
+
+  logger.debug({ url: repo.clone_url }, `using HTTP URL`);
+  const repoUrl = parseUrl(repo.clone_url);
+  if (!repoUrl) {
+    throw new Error(CONFIG_GIT_URL_UNAVAILABLE);
+  }
+  repoUrl.username = opts.token ?? '';
+  return repoUrl.toString();
+}
-- 
GitLab