import type {
  BranchStatusConfig,
  EnsureIssueConfig,
  Platform,
  RepoParams,
  RepoResult,
} from '..';
import { mocked, partial } from '../../../../test/util';
import { PlatformId } from '../../../constants';
import {
  CONFIG_GIT_URL_UNAVAILABLE,
  REPOSITORY_ACCESS_FORBIDDEN,
  REPOSITORY_ARCHIVED,
  REPOSITORY_BLOCKED,
  REPOSITORY_CHANGED,
  REPOSITORY_EMPTY,
  REPOSITORY_MIRRORED,
} from '../../../constants/error-messages';
import type { logger as _logger } from '../../../logger';
import { BranchStatus, PrState } from '../../../types';
import type * as _git from '../../../util/git';
import { setBaseUrl } from '../../../util/http/gitea';
import type { PlatformResult } from '../types';
import type {
  Branch,
  CombinedCommitStatus,
  Comment,
  CommitStatus,
  CommitStatusType,
  CommitUser,
  Issue,
  Label,
  PR,
  PRState,
  Repo,
  RepoContents,
  User,
} from './types';

/**
 * latest tested gitea version.
 */
const GITEA_VERSION = '1.14.0+dev-754-g5d2b7ba63';

describe('modules/platform/gitea/index', () => {
  let gitea: Platform;
  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';

  const mockUser: User = {
    id: 1,
    username: 'renovate',
    full_name: 'Renovate Bot',
    email: 'renovate@example.com',
  };

  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,
    },
  });

  type MockPr = PR & Required<Pick<PR, 'head' | 'base'>>;

  const mockRepos: Repo[] = [
    partial<Repo>({ full_name: 'a/b' }),
    partial<Repo>({ full_name: 'c/d' }),
    partial<Repo>({ full_name: 'e/f', mirror: true }),
  ];

  const mockPRs: MockPr[] = [
    partial<MockPr>({
      number: 1,
      title: 'Some PR',
      body: 'some random pull request',
      state: PrState.Open,
      diff_url: 'https://gitea.renovatebot.com/some/repo/pulls/1.diff',
      created_at: '2015-03-22T20:36:16Z',
      closed_at: undefined,
      mergeable: true,
      base: { ref: 'some-base-branch' },
      head: {
        label: 'some-head-branch',
        sha: 'some-head-sha',
        repo: partial<Repo>({ full_name: mockRepo.full_name }),
      },
    }),
    partial<MockPr>({
      number: 2,
      title: 'Other PR',
      body: 'other random pull request',
      state: PrState.Closed,
      diff_url: 'https://gitea.renovatebot.com/some/repo/pulls/2.diff',
      created_at: '2011-08-18T22:30:38Z',
      closed_at: '2016-01-09T10:03:21Z',
      mergeable: true,
      base: { ref: 'other-base-branch' },
      head: {
        label: 'other-head-branch',
        sha: 'other-head-sha',
        repo: partial<Repo>({ full_name: mockRepo.full_name }),
      },
    }),
    partial<MockPr>({
      number: 3,
      title: 'WIP: Draft PR',
      body: 'other random pull request',
      state: PrState.Open,
      diff_url: 'https://gitea.renovatebot.com/some/repo/pulls/3.diff',
      created_at: '2011-08-18T22:30:39Z',
      closed_at: '2016-01-09T10:03:22Z',
      mergeable: true,
      base: { ref: 'draft-base-branch' },
      head: {
        label: 'draft-head-branch',
        sha: 'draft-head-sha',
        repo: partial<Repo>({ full_name: mockRepo.full_name }),
      },
    }),
  ];

  const mockIssues: Issue[] = [
    {
      number: 1,
      title: 'open-issue',
      state: 'open',
      body: 'some-content',
      assignees: [],
      labels: [],
    },
    {
      number: 2,
      title: 'closed-issue',
      state: 'closed',
      body: 'other-content',
      assignees: [],
      labels: undefined as never, // coverage
    },
    {
      number: 3,
      title: 'duplicate-issue',
      state: 'open',
      body: 'duplicate-content',
      assignees: [],
      labels: [],
    },
    {
      number: 4,
      title: 'duplicate-issue',
      state: 'open',
      body: 'duplicate-content',
      assignees: [],
      labels: [],
    },
    {
      number: 5,
      title: 'duplicate-issue',
      state: 'open',
      body: 'duplicate-content',
      assignees: [],
      labels: [],
    },
  ];

  const mockComments: Comment[] = [
    { id: 11, body: 'some-body' },
    { id: 12, body: 'other-body' },
    { id: 13, body: '### some-topic\n\nsome-content' },
  ];

  const mockRepoLabels: Label[] = [
    { id: 1, name: 'some-label', description: 'its a me', color: '#000000' },
    { id: 2, name: 'other-label', description: 'labelario', color: '#ffffff' },
  ];

  const mockOrgLabels: Label[] = [
    {
      id: 3,
      name: 'some-org-label',
      description: 'its a org me',
      color: '#0000aa',
    },
    {
      id: 4,
      name: 'other-org-label',
      description: 'org labelario',
      color: '#ffffaa',
    },
  ];

  beforeEach(async () => {
    jest.resetModules();
    jest.clearAllMocks();
    jest.mock('./gitea-helper');
    jest.mock('../../../util/git');
    jest.mock('../../../logger');

    gitea = await import('.');
    helper = mocked(await import('./gitea-helper'));
    logger = mocked((await import('../../../logger')).logger);
    gitvcs = require('../../../util/git');
    gitvcs.isBranchBehindBase.mockResolvedValue(false);
    gitvcs.getBranchCommit.mockReturnValue(mockCommitHash);
    hostRules = mocked(await import('../../../util/host-rules'));
    hostRules.clear();

    setBaseUrl('https://gitea.renovatebot.com/');

    delete process.env.RENOVATE_X_AUTODISCOVER_REPO_SORT;
    delete process.env.RENOVATE_X_AUTODISCOVER_REPO_ORDER;
  });

  function initFakePlatform(version = GITEA_VERSION): Promise<PlatformResult> {
    helper.getCurrentUser.mockResolvedValueOnce(mockUser);
    helper.getVersion.mockResolvedValueOnce(version);
    return gitea.initPlatform({ token: 'abc' });
  }

  function initFakeRepo(
    repo?: Partial<Repo>,
    config?: Partial<RepoParams>
  ): Promise<RepoResult> {
    helper.getRepo.mockResolvedValueOnce({ ...mockRepo, ...repo });

    return gitea.initRepo({
      repository: mockRepo.full_name,
      ...config,
    });
  }

  describe('initPlatform()', () => {
    it('should throw if no token', async () => {
      await expect(gitea.initPlatform({})).rejects.toThrow();
    });

    it('should throw if auth fails', async () => {
      helper.getCurrentUser.mockRejectedValueOnce(new Error());

      await expect(
        gitea.initPlatform({ token: 'some-token' })
      ).rejects.toThrow();
    });

    it('should support default endpoint', async () => {
      helper.getCurrentUser.mockResolvedValueOnce(mockUser);

      expect(
        await gitea.initPlatform({ token: 'some-token' })
      ).toMatchSnapshot();
    });

    it('should support custom endpoint', async () => {
      helper.getCurrentUser.mockResolvedValueOnce(mockUser);

      expect(
        await gitea.initPlatform({
          token: 'some-token',
          endpoint: 'https://gitea.renovatebot.com',
        })
      ).toMatchSnapshot();
    });

    it('should support custom endpoint including api path', async () => {
      helper.getCurrentUser.mockResolvedValueOnce(mockUser);

      expect(
        await gitea.initPlatform({
          token: 'some-token',
          endpoint: 'https://gitea.renovatebot.com/api/v1',
        })
      ).toMatchObject({
        endpoint: 'https://gitea.renovatebot.com/',
      });
    });

    it('should use username as author name if full name is missing', async () => {
      helper.getCurrentUser.mockResolvedValueOnce({
        ...mockUser,
        full_name: undefined,
      });

      expect(
        await gitea.initPlatform({ token: 'some-token' })
      ).toMatchSnapshot();
    });
  });

  describe('getRepos', () => {
    it('should propagate any other errors', async () => {
      helper.searchRepos.mockRejectedValueOnce(new Error('searchRepos()'));

      await expect(gitea.getRepos()).rejects.toThrow('searchRepos()');
    });

    it('should return an array of repos', async () => {
      helper.searchRepos.mockResolvedValueOnce(mockRepos);

      const repos = await gitea.getRepos();
      expect(repos).toEqual(['a/b', 'c/d']);
      expect(helper.searchRepos).toHaveBeenCalledWith({
        uid: undefined,
        archived: false,
      });
    });

    it('Sorts repos', async () => {
      process.env.RENOVATE_X_AUTODISCOVER_REPO_SORT = 'updated';
      process.env.RENOVATE_X_AUTODISCOVER_REPO_ORDER = 'desc';
      helper.searchRepos.mockResolvedValueOnce(mockRepos);

      const repos = await gitea.getRepos();
      expect(repos).toEqual(['a/b', 'c/d']);

      expect(helper.searchRepos).toHaveBeenCalledWith({
        uid: undefined,
        archived: false,
        sort: 'updated',
        order: 'desc',
      });
    });
  });

  describe('initRepo', () => {
    const initRepoCfg: RepoParams = {
      repository: mockRepo.full_name,
    };

    it('should propagate API errors', async () => {
      helper.getRepo.mockRejectedValueOnce(new Error('getRepo()'));

      await expect(gitea.initRepo(initRepoCfg)).rejects.toThrow('getRepo()');
    });

    it('should abort when repo is archived', async () => {
      await expect(initFakeRepo({ archived: true })).rejects.toThrow(
        REPOSITORY_ARCHIVED
      );
    });

    it('should abort when repo is mirrored', async () => {
      await expect(initFakeRepo({ mirror: true })).rejects.toThrow(
        REPOSITORY_MIRRORED
      );
    });

    it('should abort when repo is empty', async () => {
      await expect(initFakeRepo({ empty: true })).rejects.toThrow(
        REPOSITORY_EMPTY
      );
    });

    it('should abort when repo has insufficient permissions', async () => {
      await expect(
        initFakeRepo({
          permissions: {
            pull: false,
            push: false,
            admin: false,
          },
        })
      ).rejects.toThrow(REPOSITORY_ACCESS_FORBIDDEN);
    });

    it('should abort when repo has no available merge methods', async () => {
      await expect(initFakeRepo({ allow_rebase: false })).rejects.toThrow(
        REPOSITORY_BLOCKED
      );
    });

    it('should fall back to merge method "rebase-merge"', async () => {
      expect(
        await initFakeRepo({ allow_rebase: false, allow_rebase_explicit: true })
      ).toMatchSnapshot();
    });

    it('should fall back to merge method "squash"', async () => {
      expect(
        await initFakeRepo({ allow_rebase: false, allow_squash_merge: true })
      ).toMatchSnapshot();
    });

    it('should fall back to merge method "merge"', async () => {
      expect(
        await initFakeRepo({
          allow_rebase: false,
          allow_merge_commits: true,
        })
      ).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);

      // TODO: types (#7154)
      // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
      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);

      // TODO: types (#7154)
      // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
      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', () => {
    const setBranchStatus = async (bsc?: Partial<BranchStatusConfig>) => {
      await initFakeRepo();
      await gitea.setBranchStatus({
        branchName: 'some-branch',
        state: BranchStatus.green,
        context: 'some-context',
        description: 'some-description',
        ...bsc,
      });
    };

    it('should create a new commit status', async () => {
      await setBranchStatus();

      expect(helper.createCommitStatus).toHaveBeenCalledTimes(1);
      expect(helper.createCommitStatus).toHaveBeenCalledWith(
        mockRepo.full_name,
        mockCommitHash,
        {
          state: 'success',
          context: 'some-context',
          description: 'some-description',
        }
      );
    });

    it('should default to pending state', async () => {
      await setBranchStatus({ state: undefined });

      expect(helper.createCommitStatus).toHaveBeenCalledTimes(1);
      expect(helper.createCommitStatus).toHaveBeenCalledWith(
        mockRepo.full_name,
        mockCommitHash,
        {
          state: 'pending',
          context: 'some-context',
          description: 'some-description',
        }
      );
    });

    it('should include url if specified', async () => {
      await setBranchStatus({ url: 'some-url' });

      expect(helper.createCommitStatus).toHaveBeenCalledTimes(1);
      expect(helper.createCommitStatus).toHaveBeenCalledWith(
        mockRepo.full_name,
        mockCommitHash,
        {
          state: 'success',
          context: 'some-context',
          description: 'some-description',
          target_url: 'some-url',
        }
      );
    });

    it('should gracefully fail with warning', async () => {
      helper.createCommitStatus.mockRejectedValueOnce(new Error());
      await setBranchStatus();

      expect(logger.warn).toHaveBeenCalledTimes(1);
    });
  });

  describe('getBranchStatus', () => {
    const getBranchStatus = async (state: string): Promise<BranchStatus> => {
      await initFakeRepo();
      helper.getCombinedCommitStatus.mockResolvedValueOnce(
        partial<CombinedCommitStatus>({
          worstStatus: state as CommitStatusType,
        })
      );

      return gitea.getBranchStatus('some-branch');
    };

    it('should return yellow for unknown result', async () => {
      expect(await getBranchStatus('unknown')).toEqual(BranchStatus.yellow);
    });

    it('should return pending state for pending result', async () => {
      expect(await getBranchStatus('pending')).toEqual(BranchStatus.yellow);
    });

    it('should return success state for success result', async () => {
      expect(await getBranchStatus('success')).toEqual(BranchStatus.green);
    });

    it('should return null for all other results', async () => {
      expect(await getBranchStatus('invalid')).toEqual(BranchStatus.yellow);
    });

    it('should abort when branch status returns 404', async () => {
      helper.getCombinedCommitStatus.mockRejectedValueOnce({ statusCode: 404 });

      await expect(gitea.getBranchStatus('some-branch')).rejects.toThrow(
        REPOSITORY_CHANGED
      );
    });

    it('should propagate any other errors', async () => {
      helper.getCombinedCommitStatus.mockRejectedValueOnce(
        new Error('getCombinedCommitStatus()')
      );

      await expect(gitea.getBranchStatus('some-branch')).rejects.toThrow(
        'getCombinedCommitStatus()'
      );
    });
  });

  describe('getBranchStatusCheck', () => {
    it('should return null with no results', async () => {
      helper.getCombinedCommitStatus.mockResolvedValueOnce(
        partial<CombinedCommitStatus>({
          statuses: [],
        })
      );

      expect(
        await gitea.getBranchStatusCheck('some-branch', 'some-context')
      ).toBeNull();
    });

    it('should return null with no matching results', async () => {
      helper.getCombinedCommitStatus.mockResolvedValueOnce(
        partial<CombinedCommitStatus>({
          statuses: [partial<CommitStatus>({ context: 'other-context' })],
        })
      );

      expect(
        await gitea.getBranchStatusCheck('some-branch', 'some-context')
      ).toBeNull();
    });

    it('should return yellow with unknown status', async () => {
      helper.getCombinedCommitStatus.mockResolvedValueOnce(
        partial<CombinedCommitStatus>({
          statuses: [
            partial<CommitStatus>({
              context: 'some-context',
            }),
          ],
        })
      );

      expect(
        await gitea.getBranchStatusCheck('some-branch', 'some-context')
      ).toEqual(BranchStatus.yellow);
    });

    it('should return green of matching result', async () => {
      helper.getCombinedCommitStatus.mockResolvedValueOnce(
        partial<CombinedCommitStatus>({
          statuses: [
            partial<CommitStatus>({
              status: 'success',
              context: 'some-context',
            }),
          ],
        })
      );

      expect(
        await gitea.getBranchStatusCheck('some-branch', 'some-context')
      ).toEqual(BranchStatus.green);
    });
  });

  describe('getPrList', () => {
    it('should return list of pull requests', async () => {
      helper.searchPRs.mockResolvedValueOnce(mockPRs);
      await initFakeRepo();

      const res = await gitea.getPrList();
      expect(res).toHaveLength(mockPRs.length);
      expect(res).toMatchSnapshot();
    });

    it('should filter list by creator', async () => {
      helper.getCurrentUser.mockResolvedValueOnce(mockUser);

      expect(
        await gitea.initPlatform({ token: 'some-token' })
      ).toMatchSnapshot();

      await initFakeRepo();

      helper.searchPRs.mockResolvedValueOnce([
        partial<PR>({
          number: 3,
          title: 'Third-party PR',
          body: 'other random pull request',
          state: PrState.Open,
          diff_url: 'https://gitea.renovatebot.com/some/repo/pulls/3.diff',
          created_at: '2011-08-18T22:30:38Z',
          closed_at: '2016-01-09T10:03:21Z',
          mergeable: true,
          base: { ref: 'third-party-base-branch' },
          head: {
            label: 'other-head-branch',
            sha: 'other-head-sha',
            repo: partial<Repo>({ full_name: mockRepo.full_name }),
          },
          user: { username: 'not-renovate' },
        }),
        ...mockPRs.map((pr) => ({ ...pr, user: { username: 'renovate' } })),
      ]);

      const res = await gitea.getPrList();
      expect(res).toHaveLength(mockPRs.length);
      expect(res).toMatchSnapshot();
    });

    it('should cache results after first query', async () => {
      helper.searchPRs.mockResolvedValueOnce(mockPRs);
      await initFakeRepo();

      const res1 = await gitea.getPrList();
      const res2 = await gitea.getPrList();
      expect(res1).toEqual(res2);
      expect(helper.searchPRs).toHaveBeenCalledTimes(1);
    });
  });

  describe('getPr', () => {
    it('should return enriched pull request which exists if open', async () => {
      const mockPR = mockPRs[0];
      helper.searchPRs.mockResolvedValueOnce(mockPRs);
      helper.getBranch.mockResolvedValueOnce(
        partial<Branch>({
          commit: {
            id: mockCommitHash,
            author: partial<CommitUser>({
              email: 'renovate@whitesourcesoftware.com',
            }),
          },
        })
      );
      await initFakeRepo();

      const res = await gitea.getPr(mockPR.number);
      expect(res).toHaveProperty('number', mockPR.number);
      expect(res).toMatchSnapshot();
    });

    it('should fallback to direct fetching if cache fails', async () => {
      const mockPR = mockPRs[0];
      helper.searchPRs.mockResolvedValueOnce([]);
      helper.getPR.mockResolvedValueOnce({ ...mockPR, mergeable: false });
      await initFakeRepo();

      const res = await gitea.getPr(mockPR.number);
      expect(res).toHaveProperty('number', mockPR.number);
      expect(res).toMatchSnapshot();
      expect(helper.getPR).toHaveBeenCalledTimes(1);
    });

    it('should return null for missing pull request', async () => {
      helper.searchPRs.mockResolvedValueOnce(mockPRs);
      await initFakeRepo();

      expect(await gitea.getPr(42)).toBeNull();
    });

    it('should block modified pull request for rebasing', async () => {
      const mockPR = mockPRs[0];
      helper.searchPRs.mockResolvedValueOnce(mockPRs);
      await initFakeRepo();

      const res = await gitea.getPr(mockPR.number);
      expect(res).toHaveProperty('number', mockPR.number);
    });
  });

  describe('findPr', () => {
    it('should find pull request without title or state', async () => {
      const mockPR = mockPRs[0];
      helper.searchPRs.mockResolvedValueOnce(mockPRs);
      await initFakeRepo();

      const res = await gitea.findPr({ branchName: mockPR.head.label });
      expect(res).toHaveProperty('sourceBranch', mockPR.head.label);
    });

    it('should find pull request with title', async () => {
      const mockPR = mockPRs[0];
      helper.searchPRs.mockResolvedValueOnce(mockPRs);
      await initFakeRepo();

      const res = await gitea.findPr({
        branchName: mockPR.head.label,
        prTitle: mockPR.title,
      });
      expect(res).toHaveProperty('sourceBranch', mockPR.head.label);
      expect(res).toHaveProperty('title', mockPR.title);
    });

    it('should find pull request with state', async () => {
      const mockPR = mockPRs[1];
      helper.searchPRs.mockResolvedValueOnce(mockPRs);
      await initFakeRepo();

      const res = await gitea.findPr({
        branchName: mockPR.head.label,
        state: mockPR.state,
      });
      expect(res).toHaveProperty('sourceBranch', mockPR.head.label);
      expect(res).toHaveProperty('state', mockPR.state);
    });

    it('should not find pull request with inverted state', async () => {
      const mockPR = mockPRs[1];
      helper.searchPRs.mockResolvedValueOnce(mockPRs);
      await initFakeRepo();

      expect(
        await gitea.findPr({
          branchName: mockPR.head.label,
          state: `!${mockPR.state}` as PRState,
        })
      ).toBeNull();
    });

    it('should find pull request with title and state', async () => {
      const mockPR = mockPRs[1];
      helper.searchPRs.mockResolvedValueOnce(mockPRs);
      await initFakeRepo();

      const res = await gitea.findPr({
        branchName: mockPR.head.label,
        prTitle: mockPR.title,
        state: mockPR.state,
      });
      expect(res).toHaveProperty('sourceBranch', mockPR.head.label);
      expect(res).toHaveProperty('title', mockPR.title);
      expect(res).toHaveProperty('state', mockPR.state);
    });

    it('should find pull request with draft', async () => {
      const mockPR = mockPRs[2];
      helper.searchPRs.mockResolvedValueOnce(mockPRs);
      await initFakeRepo();

      const res = await gitea.findPr({
        branchName: mockPR.head.label,
        prTitle: 'Draft PR',
        state: mockPR.state,
      });
      expect(res).toHaveProperty('sourceBranch', mockPR.head.label);
      expect(res).toHaveProperty('title', 'Draft PR');
      expect(res).toHaveProperty('state', mockPR.state);
    });

    it('should return null for missing pull request', async () => {
      helper.searchPRs.mockResolvedValueOnce(mockPRs);
      await initFakeRepo();

      expect(await gitea.findPr({ branchName: 'missing' })).toBeNull();
    });
  });

  describe('createPr', () => {
    const mockNewPR: MockPr = {
      number: 42,
      state: PrState.Open,
      head: {
        label: 'pr-branch',
        sha: mockCommitHash,
        repo: partial<Repo>({ full_name: mockRepo.full_name }),
      },
      base: {
        ref: mockRepo.default_branch,
      },
      diff_url: 'https://gitea.renovatebot.com/some/repo/pulls/42.diff',
      title: 'pr-title',
      body: 'pr-body',
      mergeable: true,
      created_at: '2014-04-01T05:14:20Z',
      closed_at: '2017-12-28T12:17:48Z',
    };

    it('should use base branch by default', async () => {
      helper.createPR.mockResolvedValueOnce({
        ...mockNewPR,
        base: { ref: 'devel' },
      });

      await initFakeRepo();
      const res = await gitea.createPr({
        sourceBranch: mockNewPR.head.label,
        targetBranch: 'devel',
        prTitle: mockNewPR.title,
        prBody: mockNewPR.body,
      });

      expect(res).toHaveProperty('number', mockNewPR.number);
      expect(res).toHaveProperty('targetBranch', 'devel');
      expect(res).toMatchSnapshot();
      expect(helper.createPR).toHaveBeenCalledTimes(1);
      expect(helper.createPR).toHaveBeenCalledWith(mockRepo.full_name, {
        base: 'devel',
        head: mockNewPR.head.label,
        title: mockNewPR.title,
        body: mockNewPR.body,
        labels: [],
      });
    });

    it('should use default branch if requested', async () => {
      helper.createPR.mockResolvedValueOnce(mockNewPR);

      await initFakeRepo();
      const res = await gitea.createPr({
        sourceBranch: mockNewPR.head.label,
        targetBranch: 'master',
        prTitle: mockNewPR.title,
        prBody: mockNewPR.body,
        draftPR: true,
      });

      expect(res).toHaveProperty('number', mockNewPR.number);
      expect(res).toHaveProperty('targetBranch', mockNewPR.base.ref);
      expect(res).toMatchSnapshot();
      expect(helper.createPR).toHaveBeenCalledTimes(1);
      expect(helper.createPR).toHaveBeenCalledWith(mockRepo.full_name, {
        base: mockNewPR.base.ref,
        head: mockNewPR.head.label,
        title: `WIP: ${mockNewPR.title}`,
        body: mockNewPR.body,
        labels: [],
      });
    });

    it('should resolve and apply optional labels to pull request', async () => {
      helper.createPR.mockResolvedValueOnce(mockNewPR);
      helper.getRepoLabels.mockResolvedValueOnce(mockRepoLabels);
      helper.getOrgLabels.mockResolvedValueOnce(mockOrgLabels);

      const mockLabels = mockRepoLabels.concat(mockOrgLabels);

      await initFakeRepo();
      await gitea.createPr({
        sourceBranch: mockNewPR.head.label,
        targetBranch: 'master',
        prTitle: mockNewPR.title,
        prBody: mockNewPR.body,
        labels: mockLabels.map((l) => l.name),
      });

      expect(helper.createPR).toHaveBeenCalledTimes(1);
      expect(helper.createPR).toHaveBeenCalledWith(mockRepo.full_name, {
        base: mockNewPR.base.ref,
        head: mockNewPR.head.label,
        title: mockNewPR.title,
        body: mockNewPR.body,
        labels: mockLabels.map((l) => l.id),
      });
    });

    it('should ensure new pull request gets added to cached pull requests', async () => {
      helper.searchPRs.mockResolvedValueOnce(mockPRs);
      helper.createPR.mockResolvedValueOnce(mockNewPR);

      await initFakeRepo();
      await gitea.getPrList();
      await gitea.createPr({
        sourceBranch: mockNewPR.head.label,
        targetBranch: 'master',
        prTitle: mockNewPR.title,
        prBody: mockNewPR.body,
      });
      const res = gitea.getPr(mockNewPR.number);

      expect(res).not.toBeNull();
      expect(helper.searchPRs).toHaveBeenCalledTimes(1);
    });

    it('should attempt to resolve 409 conflict error (w/o update)', async () => {
      helper.createPR.mockRejectedValueOnce({ statusCode: 409 });
      helper.searchPRs.mockResolvedValueOnce([mockNewPR]);

      await initFakeRepo();
      const res = await gitea.createPr({
        sourceBranch: mockNewPR.head.label,
        targetBranch: 'master',
        prTitle: mockNewPR.title,
        prBody: mockNewPR.body,
      });

      expect(res).toHaveProperty('number', mockNewPR.number);
    });

    it('should attempt to resolve 409 conflict error (w/ update)', async () => {
      helper.createPR.mockRejectedValueOnce({ statusCode: 409 });
      helper.searchPRs.mockResolvedValueOnce([mockNewPR]);

      await initFakeRepo();
      const res = await gitea.createPr({
        sourceBranch: mockNewPR.head.label,
        targetBranch: 'master',
        prTitle: 'new-title',
        prBody: 'new-body',
      });

      expect(res).toHaveProperty('number', mockNewPR.number);
      expect(helper.updatePR).toHaveBeenCalledTimes(1);
      expect(helper.updatePR).toHaveBeenCalledWith(
        mockRepo.full_name,
        mockNewPR.number,
        { title: 'new-title', body: 'new-body' }
      );
    });

    it('should abort when response for created pull request is invalid', async () => {
      helper.createPR.mockResolvedValueOnce(partial<PR>({}));

      await initFakeRepo();
      await expect(
        gitea.createPr({
          sourceBranch: mockNewPR.head.label,
          targetBranch: 'master',
          prTitle: mockNewPR.title,
          prBody: mockNewPR.body,
        })
      ).rejects.toThrow();
    });

    it('should use platform automerge', async () => {
      helper.createPR.mockResolvedValueOnce(mockNewPR);
      await initFakePlatform('1.17.0');
      await initFakeRepo();
      const res = await gitea.createPr({
        sourceBranch: mockNewPR.head.label,
        targetBranch: 'master',
        prTitle: mockNewPR.title,
        prBody: mockNewPR.body,
        platformOptions: { usePlatformAutomerge: true },
      });

      expect(res).toHaveProperty('number', mockNewPR.number);
      expect(res).toHaveProperty('targetBranch', mockNewPR.base.ref);

      expect(helper.createPR).toHaveBeenCalledTimes(1);
      expect(helper.createPR).toHaveBeenCalledWith(mockRepo.full_name, {
        base: mockNewPR.base.ref,
        head: mockNewPR.head.label,
        title: mockNewPR.title,
        body: mockNewPR.body,
        labels: [],
      });
      expect(helper.mergePR).toHaveBeenCalledWith(
        mockRepo.full_name,
        mockNewPR.number,
        {
          Do: 'rebase',
          merge_when_checks_succeed: true,
        }
      );
    });

    it('continues on platform automerge error', async () => {
      helper.createPR.mockResolvedValueOnce(mockNewPR);
      await initFakePlatform('1.17.0');
      await initFakeRepo();
      helper.mergePR.mockRejectedValueOnce(new Error('fake'));
      const res = await gitea.createPr({
        sourceBranch: mockNewPR.head.label,
        targetBranch: 'master',
        prTitle: mockNewPR.title,
        prBody: mockNewPR.body,
        platformOptions: { usePlatformAutomerge: true },
      });

      expect(res).toHaveProperty('number', mockNewPR.number);
      expect(res).toHaveProperty('targetBranch', mockNewPR.base.ref);

      expect(helper.createPR).toHaveBeenCalledTimes(1);
      expect(helper.createPR).toHaveBeenCalledWith(mockRepo.full_name, {
        base: mockNewPR.base.ref,
        head: mockNewPR.head.label,
        title: mockNewPR.title,
        body: mockNewPR.body,
        labels: [],
      });
      expect(helper.mergePR).toHaveBeenCalledWith(
        mockRepo.full_name,
        mockNewPR.number,
        {
          Do: 'rebase',
          merge_when_checks_succeed: true,
        }
      );
    });

    it('continues if platform automerge is not supported', async () => {
      helper.createPR.mockResolvedValueOnce(mockNewPR);
      await initFakeRepo();
      const res = await gitea.createPr({
        sourceBranch: mockNewPR.head.label,
        targetBranch: 'master',
        prTitle: mockNewPR.title,
        prBody: mockNewPR.body,
        platformOptions: { usePlatformAutomerge: true },
      });

      expect(res).toHaveProperty('number', mockNewPR.number);
      expect(res).toHaveProperty('targetBranch', mockNewPR.base.ref);

      expect(helper.createPR).toHaveBeenCalledTimes(1);
      expect(helper.createPR).toHaveBeenCalledWith(mockRepo.full_name, {
        base: mockNewPR.base.ref,
        head: mockNewPR.head.label,
        title: mockNewPR.title,
        body: mockNewPR.body,
        labels: [],
      });
      expect(helper.mergePR).not.toHaveBeenCalled();
    });
  });

  describe('updatePr', () => {
    it('should update pull request with title', async () => {
      helper.searchPRs.mockResolvedValueOnce(mockPRs);
      await initFakeRepo();
      await gitea.updatePr({ number: 1, prTitle: 'New Title' });

      expect(helper.updatePR).toHaveBeenCalledTimes(1);
      expect(helper.updatePR).toHaveBeenCalledWith(mockRepo.full_name, 1, {
        title: 'New Title',
      });
    });

    it('should update pull request with title and body', async () => {
      helper.searchPRs.mockResolvedValueOnce(mockPRs);
      await initFakeRepo();
      await gitea.updatePr({
        number: 1,
        prTitle: 'New Title',
        prBody: 'New Body',
      });

      expect(helper.updatePR).toHaveBeenCalledTimes(1);
      expect(helper.updatePR).toHaveBeenCalledWith(mockRepo.full_name, 1, {
        title: 'New Title',
        body: 'New Body',
      });
    });

    it('should update pull request with draft', async () => {
      helper.searchPRs.mockResolvedValueOnce(mockPRs);
      await initFakeRepo();
      await gitea.updatePr({
        number: 3,
        prTitle: 'New Title',
        prBody: 'New Body',
      });

      expect(helper.updatePR).toHaveBeenCalledTimes(1);
      expect(helper.updatePR).toHaveBeenCalledWith(mockRepo.full_name, 3, {
        title: 'WIP: New Title',
        body: 'New Body',
      });
    });

    it('should close pull request', async () => {
      helper.searchPRs.mockResolvedValueOnce(mockPRs);
      await initFakeRepo();
      await gitea.updatePr({
        number: 1,
        prTitle: 'New Title',
        prBody: 'New Body',
        state: PrState.Closed,
      });

      expect(helper.updatePR).toHaveBeenCalledWith(mockRepo.full_name, 1, {
        title: 'New Title',
        body: 'New Body',
        state: PrState.Closed,
      });
    });
  });

  describe('mergePr', () => {
    it('should return true when merging succeeds', async () => {
      await initFakeRepo();

      expect(
        await gitea.mergePr({
          branchName: 'some-branch',
          id: 1,
        })
      ).toBe(true);
      expect(helper.mergePR).toHaveBeenCalledTimes(1);
      expect(helper.mergePR).toHaveBeenCalledWith(mockRepo.full_name, 1, {
        Do: 'rebase',
      });
    });

    it('should return false when merging fails', async () => {
      helper.mergePR.mockRejectedValueOnce(new Error());
      await initFakeRepo();

      expect(
        await gitea.mergePr({
          branchName: 'some-branch',
          id: 1,
          strategy: 'squash',
        })
      ).toBe(false);
    });
  });

  describe('getIssue', () => {
    it('should return the issue', async () => {
      const mockIssue = mockIssues.find((i) => i.number === 1)!;
      helper.getIssue.mockResolvedValueOnce(mockIssue);
      await initFakeRepo();

      expect(await gitea.getIssue?.(mockIssue.number)).toHaveProperty(
        'number',
        mockIssue.number
      );
    });
  });

  describe('findIssue', () => {
    it('should return existing open issue', async () => {
      const mockIssue = mockIssues.find((i) => i.title === 'open-issue')!;
      helper.searchIssues.mockResolvedValueOnce(mockIssues);
      helper.getIssue.mockResolvedValueOnce(mockIssue);
      await initFakeRepo();

      expect(await gitea.findIssue(mockIssue.title)).toHaveProperty(
        'number',
        mockIssue.number
      );
    });

    it('should not return existing closed issue', async () => {
      const mockIssue = mockIssues.find((i) => i.title === 'closed-issue')!;
      helper.searchIssues.mockResolvedValueOnce(mockIssues);
      await initFakeRepo();

      expect(await gitea.findIssue(mockIssue.title)).toBeNull();
    });

    it('should return null for missing issue', async () => {
      helper.searchIssues.mockResolvedValueOnce(mockIssues);
      await initFakeRepo();

      expect(await gitea.findIssue('missing')).toBeNull();
    });
  });

  describe('ensureIssue', () => {
    it('should create issue if not found', async () => {
      const mockIssue = {
        title: 'new-title',
        body: 'new-body',
        shouldReOpen: false,
        once: false,
      };

      helper.searchIssues.mockResolvedValueOnce(mockIssues);
      helper.createIssue.mockResolvedValueOnce(partial<Issue>({ number: 42 }));

      await initFakeRepo();
      const res = await gitea.ensureIssue(mockIssue);

      expect(res).toBe('created');
      expect(helper.createIssue).toHaveBeenCalledTimes(1);
      expect(helper.createIssue).toHaveBeenCalledWith(mockRepo.full_name, {
        body: mockIssue.body,
        title: mockIssue.title,
      });
    });

    it('should create issue with the correct labels', async () => {
      const mockIssue: EnsureIssueConfig = {
        title: 'new-title',
        body: 'new-body',
        shouldReOpen: false,
        once: false,
        labels: ['Renovate', 'Maintenance'],
      };
      const mockLabels: Label[] = [
        partial<Label>({ id: 1, name: 'Renovate' }),
        partial<Label>({ id: 3, name: 'Maintenance' }),
      ];

      helper.getRepoLabels.mockResolvedValueOnce(partial(mockLabels));
      helper.getOrgLabels.mockResolvedValueOnce([]);

      helper.searchIssues.mockResolvedValueOnce(mockIssues);
      helper.createIssue.mockResolvedValueOnce(partial<Issue>({ number: 42 }));

      await initFakeRepo();
      const res = await gitea.ensureIssue(mockIssue);

      expect(res).toBe('created');
      expect(helper.createIssue).toHaveBeenCalledTimes(1);
      expect(helper.createIssue).toHaveBeenCalledWith(mockRepo.full_name, {
        body: mockIssue.body,
        title: mockIssue.title,
        labels: [1, 3],
      });
    });

    it('should not reopen closed issue by default', async () => {
      const closedIssue = mockIssues.find((i) => i.title === 'closed-issue')!;
      helper.searchIssues.mockResolvedValueOnce(mockIssues);
      helper.updateIssue.mockResolvedValueOnce(closedIssue);

      await initFakeRepo();
      const res = await gitea.ensureIssue({
        title: closedIssue.title,
        body: closedIssue.body,
        shouldReOpen: false,
        once: false,
      });

      expect(res).toBe('updated');
      expect(helper.updateIssue).toHaveBeenCalledTimes(1);
      expect(helper.updateIssue).toHaveBeenCalledWith(
        mockRepo.full_name,
        closedIssue.number,
        {
          body: closedIssue.body,
          state: closedIssue.state,
          title: 'closed-issue',
        }
      );
    });

    it('should not update labels when not necessary', async () => {
      const mockLabels: Label[] = [
        partial<Label>({ id: 1, name: 'Renovate' }),
        partial<Label>({ id: 3, name: 'Maintenance' }),
      ];
      const mockIssue: Issue = {
        number: 10,
        title: 'label-issue',
        body: 'label-body',
        assignees: [],
        labels: mockLabels,
        state: 'open',
      };

      helper.getRepoLabels.mockResolvedValueOnce(partial(mockLabels));
      helper.getOrgLabels.mockResolvedValueOnce([]);
      helper.searchIssues.mockResolvedValueOnce([mockIssue]);
      helper.updateIssue.mockResolvedValueOnce(mockIssue);

      await initFakeRepo();
      const res = await gitea.ensureIssue({
        title: mockIssue.title,
        body: 'new-body',
        labels: ['Renovate', 'Maintenance'],
      });

      expect(res).toBe('updated');
      expect(helper.updateIssue).toHaveBeenCalledTimes(1);
      expect(helper.updateIssueLabels).toHaveBeenCalledTimes(0);
    });

    it('should update labels when missing', async () => {
      const mockLabels: Label[] = [
        partial<Label>({ id: 1, name: 'Renovate' }),
        partial<Label>({ id: 3, name: 'Maintenance' }),
      ];
      const mockIssue: Issue = {
        number: 10,
        title: 'label-issue',
        body: 'label-body',
        assignees: [],
        labels: [mockLabels[0]],
        state: 'open',
      };

      helper.getRepoLabels.mockResolvedValueOnce(partial(mockLabels));
      helper.getOrgLabels.mockResolvedValueOnce([]);
      helper.searchIssues.mockResolvedValueOnce([mockIssue]);
      helper.updateIssue.mockResolvedValueOnce(mockIssue);

      await initFakeRepo();
      const res = await gitea.ensureIssue({
        title: mockIssue.title,
        body: 'new-body',
        labels: ['Renovate', 'Maintenance'],
      });

      expect(res).toBe('updated');
      expect(helper.updateIssue).toHaveBeenCalledTimes(1);
      expect(helper.updateIssueLabels).toHaveBeenCalledTimes(1);
      expect(helper.updateIssueLabels).toHaveBeenCalledWith(
        mockRepo.full_name,
        mockIssue.number,
        {
          labels: [1, 3],
        }
      );
    });

    it('should reset labels when others have been set', async () => {
      const mockLabels: Label[] = [
        partial<Label>({ id: 1, name: 'Renovate' }),
        partial<Label>({ id: 2, name: 'Other label' }),
        partial<Label>({ id: 3, name: 'Maintenance' }),
      ];
      const mockIssue: Issue = {
        number: 10,
        title: 'label-issue',
        body: 'label-body',
        assignees: [],
        labels: mockLabels,
        state: 'open',
      };

      helper.getRepoLabels.mockResolvedValueOnce(partial(mockLabels));
      helper.getOrgLabels.mockResolvedValueOnce([]);
      helper.searchIssues.mockResolvedValueOnce([mockIssue]);
      helper.updateIssue.mockResolvedValueOnce(mockIssue);

      await initFakeRepo();
      const res = await gitea.ensureIssue({
        title: mockIssue.title,
        body: 'new-body',
        labels: ['Renovate', 'Maintenance'],
      });

      expect(res).toBe('updated');
      expect(helper.updateIssue).toHaveBeenCalledTimes(1);
      expect(helper.updateIssueLabels).toHaveBeenCalledTimes(1);
      expect(helper.updateIssueLabels).toHaveBeenCalledWith(
        mockRepo.full_name,
        mockIssue.number,
        {
          labels: [1, 3],
        }
      );
    });

    it('should reopen closed issue if desired', async () => {
      const closedIssue = mockIssues.find((i) => i.title === 'closed-issue')!;
      helper.searchIssues.mockResolvedValueOnce(mockIssues);
      helper.updateIssue.mockResolvedValueOnce(closedIssue);

      await initFakeRepo();
      const res = await gitea.ensureIssue({
        title: closedIssue.title,
        body: closedIssue.body,
        shouldReOpen: true,
        once: false,
      });

      expect(res).toBe('updated');
      expect(helper.updateIssue).toHaveBeenCalledTimes(1);
      expect(helper.updateIssue).toHaveBeenCalledWith(
        mockRepo.full_name,
        closedIssue.number,
        {
          body: closedIssue.body,
          state: 'open',
          title: 'closed-issue',
        }
      );
    });

    it('should not update existing closed issue if desired', async () => {
      const closedIssue = mockIssues.find((i) => i.title === 'closed-issue')!;
      helper.searchIssues.mockResolvedValueOnce(mockIssues);

      await initFakeRepo();
      const res = await gitea.ensureIssue({
        title: closedIssue.title,
        body: closedIssue.body,
        shouldReOpen: false,
        once: true,
      });

      expect(res).toBeNull();
      expect(helper.updateIssue).not.toHaveBeenCalled();
    });

    it('should close all open duplicate issues except first one when updating', async () => {
      const duplicates = mockIssues.filter(
        (i) => i.title === 'duplicate-issue'
      );
      const firstDuplicate = duplicates[0];
      helper.searchIssues.mockResolvedValueOnce(duplicates);

      await initFakeRepo();
      const res = await gitea.ensureIssue({
        title: firstDuplicate.title,
        body: firstDuplicate.body,
        shouldReOpen: false,
        once: false,
      });

      expect(res).toBeNull();
      expect(helper.closeIssue).toHaveBeenCalledTimes(duplicates.length - 1);
      for (const issue of duplicates) {
        if (issue.number !== firstDuplicate.number) {
          // eslint-disable-next-line jest/no-conditional-expect
          expect(helper.closeIssue).toHaveBeenCalledWith(
            mockRepo.full_name,
            issue.number
          );
        }
      }
      expect(helper.updateIssue).not.toHaveBeenCalled();
    });

    it('should reset issue cache when creating an issue', async () => {
      helper.searchIssues.mockResolvedValueOnce(mockIssues);
      helper.searchIssues.mockResolvedValueOnce(mockIssues);
      helper.createIssue.mockResolvedValueOnce(partial<Issue>({ number: 42 }));

      await initFakeRepo();
      await gitea.ensureIssue({
        title: 'new-title',
        body: 'new-body',
        shouldReOpen: false,
        once: false,
      });
      await gitea.getIssueList();

      expect(helper.searchIssues).toHaveBeenCalledTimes(2);
    });

    it('should gracefully fail with warning', async () => {
      helper.searchIssues.mockRejectedValueOnce(new Error());
      await initFakeRepo();
      await gitea.ensureIssue({
        title: 'new-title',
        body: 'new-body',
        shouldReOpen: false,
        once: false,
      });

      expect(logger.warn).toHaveBeenCalledTimes(1);
    });
  });

  describe('ensureIssueClosing', () => {
    it('should close issues with matching title', async () => {
      const mockIssue = mockIssues[0];
      helper.searchIssues.mockResolvedValueOnce(mockIssues);
      await initFakeRepo();
      await gitea.ensureIssueClosing(mockIssue.title);

      expect(helper.closeIssue).toHaveBeenCalledTimes(1);
      expect(helper.closeIssue).toHaveBeenCalledWith(
        mockRepo.full_name,
        mockIssue.number
      );
    });
  });

  describe('deleteLabel', () => {
    it('should delete a label which exists', async () => {
      const mockLabel = mockRepoLabels[0];
      helper.getRepoLabels.mockResolvedValueOnce(mockRepoLabels);
      helper.getOrgLabels.mockRejectedValueOnce(new Error());
      await initFakeRepo();
      await gitea.deleteLabel(42, mockLabel.name);

      expect(helper.unassignLabel).toHaveBeenCalledTimes(1);
      expect(helper.unassignLabel).toHaveBeenCalledWith(
        mockRepo.full_name,
        42,
        mockLabel.id
      );
    });

    it('should gracefully fail with warning if label is missing', async () => {
      helper.getRepoLabels.mockResolvedValueOnce(mockRepoLabels);
      helper.getOrgLabels.mockResolvedValueOnce([]);
      await initFakeRepo();
      await gitea.deleteLabel(42, 'missing');

      expect(helper.unassignLabel).not.toHaveBeenCalled();
      expect(logger.warn).toHaveBeenCalledTimes(1);
    });
  });

  describe('getRepoForceRebase', () => {
    it('should return false - unsupported by platform', async () => {
      expect(await gitea.getRepoForceRebase()).toBe(false);
    });
  });

  describe('ensureComment', () => {
    it('should add comment with topic if not found', async () => {
      helper.getComments.mockResolvedValueOnce(mockComments);
      helper.createComment.mockResolvedValueOnce(partial<Comment>({ id: 42 }));

      await initFakeRepo();
      const res = await gitea.ensureComment({
        number: 1,
        topic: 'other-topic',
        content: 'other-content',
      });
      const body = '### other-topic\n\nother-content';

      expect(res).toBe(true);
      expect(helper.updateComment).not.toHaveBeenCalled();
      expect(helper.createComment).toHaveBeenCalledTimes(1);
      expect(helper.createComment).toHaveBeenCalledWith(
        mockRepo.full_name,
        1,
        body
      );
    });

    it('should add comment without topic if not found', async () => {
      helper.getComments.mockResolvedValueOnce(mockComments);
      helper.createComment.mockResolvedValueOnce(partial<Comment>({ id: 42 }));

      await initFakeRepo();
      const res = await gitea.ensureComment({
        number: 1,
        content: 'other-content',
        topic: null,
      });

      expect(res).toBe(true);
      expect(helper.updateComment).not.toHaveBeenCalled();
      expect(helper.createComment).toHaveBeenCalledTimes(1);
      expect(helper.createComment).toHaveBeenCalledWith(
        mockRepo.full_name,
        1,
        'other-content'
      );
    });

    it('should update comment with topic if found', async () => {
      helper.getComments.mockResolvedValueOnce(mockComments);
      helper.updateComment.mockResolvedValueOnce(partial<Comment>({ id: 13 }));

      await initFakeRepo();
      const res = await gitea.ensureComment({
        number: 1,
        topic: 'some-topic',
        content: 'some-new-content',
      });
      const body = '### some-topic\n\nsome-new-content';

      expect(res).toBe(true);
      expect(helper.createComment).not.toHaveBeenCalled();
      expect(helper.updateComment).toHaveBeenCalledTimes(1);
      expect(helper.updateComment).toHaveBeenCalledWith(
        mockRepo.full_name,
        13,
        body
      );
    });

    it('should skip if comment is up-to-date', async () => {
      helper.getComments.mockResolvedValueOnce(mockComments);
      await initFakeRepo();
      const res = await gitea.ensureComment({
        number: 1,
        topic: 'some-topic',
        content: 'some-content',
      });

      expect(res).toBe(true);
      expect(helper.createComment).not.toHaveBeenCalled();
      expect(helper.updateComment).not.toHaveBeenCalled();
    });

    it('should gracefully fail with warning', async () => {
      helper.getComments.mockRejectedValueOnce(new Error());
      await initFakeRepo();
      const res = await gitea.ensureComment({
        number: 1,
        topic: 'some-topic',
        content: 'some-content',
      });

      expect(res).toBe(false);
      expect(logger.warn).toHaveBeenCalledTimes(1);
    });
  });

  describe('ensureCommentRemoval', () => {
    it('should remove existing comment by topic', async () => {
      helper.getComments.mockResolvedValueOnce(mockComments);
      await initFakeRepo();
      await gitea.ensureCommentRemoval({
        type: 'by-topic',
        number: 1,
        topic: 'some-topic',
      });

      expect(helper.deleteComment).toHaveBeenCalledTimes(1);
      expect(helper.deleteComment).toHaveBeenCalledWith(mockRepo.full_name, 13);
    });

    it('should remove existing comment by content', async () => {
      helper.getComments.mockResolvedValueOnce(mockComments);
      await initFakeRepo();
      await gitea.ensureCommentRemoval({
        type: 'by-content',
        number: 1,
        content: 'some-body',
      });

      expect(helper.deleteComment).toHaveBeenCalledTimes(1);
      expect(helper.deleteComment).toHaveBeenCalledWith(mockRepo.full_name, 11);
    });

    it('should gracefully fail with warning', async () => {
      helper.getComments.mockResolvedValueOnce(mockComments);
      helper.deleteComment.mockRejectedValueOnce(new Error());
      await initFakeRepo();
      await gitea.ensureCommentRemoval({
        type: 'by-topic',
        number: 1,
        topic: 'some-topic',
      });

      expect(logger.warn).toHaveBeenCalledTimes(1);
    });

    it('should abort silently if comment is missing', async () => {
      helper.getComments.mockResolvedValueOnce(mockComments);
      await initFakeRepo();
      await gitea.ensureCommentRemoval({
        type: 'by-topic',
        number: 1,
        topic: 'missing',
      });

      expect(helper.deleteComment).not.toHaveBeenCalled();
    });
  });

  describe('getBranchPr', () => {
    it('should return existing pull request for branch', async () => {
      const mockPR = mockPRs[0];
      helper.searchPRs.mockResolvedValueOnce(mockPRs);
      await initFakeRepo();

      expect(await gitea.getBranchPr(mockPR.head.label)).toHaveProperty(
        'number',
        mockPR.number
      );
    });

    it('should return null if no pull request exists', async () => {
      helper.searchPRs.mockResolvedValueOnce(mockPRs);
      await initFakeRepo();

      expect(await gitea.getBranchPr('missing')).toBeNull();
    });
  });

  describe('addAssignees', () => {
    it('should add assignees to the issue', async () => {
      await initFakeRepo();
      await gitea.addAssignees(1, ['me', 'you']);

      expect(helper.updateIssue).toHaveBeenCalledTimes(1);
      expect(helper.updateIssue).toHaveBeenCalledWith(mockRepo.full_name, 1, {
        assignees: ['me', 'you'],
      });
    });
  });

  describe('addReviewers', () => {
    it('should assign reviewers', async () => {
      expect.assertions(3);
      await initFakePlatform();
      const mockPR = mockPRs[0];
      await expect(
        gitea.addReviewers(mockPR.number, ['me', 'you'])
      ).resolves.not.toThrow();

      expect(helper.requestPrReviewers).toHaveBeenCalledTimes(1);
      expect(logger.warn).not.toHaveBeenCalled();
    });

    it('should should do nothing if version to old', async () => {
      expect.assertions(3);
      const mockPR = mockPRs[0];
      await expect(
        gitea.addReviewers(mockPR.number, ['me', 'you'])
      ).resolves.not.toThrow();

      expect(helper.requestPrReviewers).not.toHaveBeenCalled();
      expect(logger.warn).not.toHaveBeenCalled();
    });

    it('catches errors', async () => {
      expect.assertions(2);
      const mockPR = mockPRs[0];
      await initFakePlatform();
      helper.requestPrReviewers.mockRejectedValueOnce(null);
      await expect(
        gitea.addReviewers(mockPR.number, ['me', 'you'])
      ).resolves.not.toThrow();
      expect(logger.warn).toHaveBeenCalled();
    });
  });

  describe('massageMarkdown', () => {
    it('replaces pr links', () => {
      const body =
        '[#123](../pull/123) [#124](../pull/124) [#125](../pull/125)';

      expect(gitea.massageMarkdown(body)).toBe(
        '[#123](pulls/123) [#124](pulls/124) [#125](pulls/125)'
      );
    });
  });

  describe('getVulnerabilityAlerts', () => {
    it('should return an empty list - unsupported by platform', async () => {
      expect(await gitea.getVulnerabilityAlerts()).toEqual([]);
    });
  });

  describe('getJsonFile()', () => {
    it('returns file content', async () => {
      const data = { foo: 'bar' };
      helper.getRepoContents.mockResolvedValueOnce({
        contentString: JSON.stringify(data),
      } as never);
      await initFakeRepo({ full_name: 'some/repo' });
      const res = await gitea.getJsonFile('file.json');
      expect(res).toEqual(data);
    });

    it('returns file content from given repo', async () => {
      const data = { foo: 'bar' };
      helper.getRepoContents.mockResolvedValueOnce({
        contentString: JSON.stringify(data),
      } as never);
      await initFakeRepo({ full_name: 'different/repo' });
      const res = await gitea.getJsonFile('file.json', 'different/repo');
      expect(res).toEqual(data);
    });

    it('returns file content from branch or tag', async () => {
      const data = { foo: 'bar' };
      helper.getRepoContents.mockResolvedValueOnce({
        contentString: JSON.stringify(data),
      } as never);
      await initFakeRepo({ full_name: 'some/repo' });
      const res = await gitea.getJsonFile('file.json', 'some/repo', 'dev');
      expect(res).toEqual(data);
    });

    it('returns file content in json5 format', async () => {
      const json5Data = `
        {
          // json5 comment
          foo: 'bar'
        }
      `;
      helper.getRepoContents.mockResolvedValueOnce({
        contentString: json5Data,
      } as never);
      await initFakeRepo({ full_name: 'some/repo' });
      const res = await gitea.getJsonFile('file.json5');
      expect(res).toEqual({ foo: 'bar' });
    });

    it('throws on malformed JSON', async () => {
      helper.getRepoContents.mockResolvedValueOnce({
        contentString: '!@#',
      } as never);
      await initFakeRepo({ full_name: 'some/repo' });
      await expect(gitea.getJsonFile('file.json')).rejects.toThrow();
    });

    it('returns null on missing content', async () => {
      helper.getRepoContents.mockResolvedValueOnce(partial<RepoContents>({}));
      await initFakeRepo({ full_name: 'some/repo' });
      expect(await gitea.getJsonFile('file.json')).toBeNull();
    });

    it('throws on errors', async () => {
      helper.getRepoContents.mockRejectedValueOnce(new Error('some error'));
      await initFakeRepo({ full_name: 'some/repo' });
      await expect(gitea.getJsonFile('file.json')).rejects.toThrow();
    });
  });
});