import type { EnsureIssueConfig, RepoParams } from '..';
import {
  CONFIG_GIT_URL_UNAVAILABLE,
  REPOSITORY_ACCESS_FORBIDDEN,
  REPOSITORY_ARCHIVED,
  REPOSITORY_BLOCKED,
  REPOSITORY_CHANGED,
  REPOSITORY_EMPTY,
  REPOSITORY_MIRRORED,
  TEMPORARY_ERROR,
} from '../../../constants/error-messages';
import * as memCache from '../../../util/cache/memory';
import * as repoCache from '../../../util/cache/repository';
import type { LongCommitSha } from '../../../util/git/types';
import * as helper from './forgejo-helper';
import type {
  Comment,
  CommitStatus,
  CommitStatusType,
  Issue,
  Label,
  PR,
  Repo,
  RepoPermission,
  User,
} from './types';
import * as forgejo from '.';
import * as httpMock from '~test/http-mock';
import { git, hostRules, logger, partial } from '~test/util';

/**
 * latest tested forgejo version.
 */
const FORGEJO_VERSION = '11.0.1-99-c504062+gitea-1.22.0';

describe('modules/platform/forgejo/index', () => {
  function mockedRepo(opts: Partial<Repo>): Repo {
    return partial<Repo>({
      permissions: partial<RepoPermission>({ push: true, pull: true }),
      has_pull_requests: true,
      ...opts,
    });
  }

  const mockCommitHash =
    '0d9c7726c3d628b7e28af234595cfd20febdbf8e' as LongCommitSha;

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

  const mockRepo = mockedRepo({
    allow_rebase: true,
    default_merge_style: 'rebase',
    clone_url: 'https://forgejo.renovatebot.com/some/repo.git',
    ssh_url: 'git@forgejo.renovatebot.com/some/repo.git',
    default_branch: 'master',
    full_name: 'some/repo',
  });

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

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

  const mockTopicRepos: Repo[] = [mockedRepo({ full_name: 'a/b' })];

  const mockNamespaceRepos: Repo[] = [
    mockedRepo({ full_name: 'org1/repo' }),
    mockedRepo({ full_name: 'org2/repo' }),
    mockedRepo({ full_name: 'org2/repo2', archived: true }),
  ];

  const mockPRs: MockPr[] = [
    partial<MockPr>({
      number: 1,
      title: 'Some PR',
      body: 'some random pull request',
      state: 'open',
      diff_url: 'https://forgejo.renovatebot.com/some/repo/pulls/1.diff',
      created_at: '2015-03-22T20:36:16Z',
      closed_at: '2015-03-22T21:36:16Z',
      updated_at: '2015-03-22T21:36:16Z',
      mergeable: true,
      base: { ref: 'some-base-branch' },
      head: {
        label: 'some-head-branch',
        sha: 'some-head-sha' as LongCommitSha,
        repo: partial<Repo>({ full_name: mockRepo.full_name }),
      },
    }),
    partial<MockPr>({
      number: 2,
      title: 'Other PR',
      body: 'other random pull request',
      state: 'closed',
      diff_url: 'https://forgejo.renovatebot.com/some/repo/pulls/2.diff',
      created_at: '2011-08-18T22:30:38Z',
      closed_at: '2016-01-09T10:03:21Z',
      updated_at: '2016-01-09T10:03:21Z',
      mergeable: true,
      base: { ref: 'other-base-branch' },
      head: {
        label: 'other-head-branch',
        sha: 'other-head-sha' as LongCommitSha,
        repo: partial<Repo>({ full_name: mockRepo.full_name }),
      },
      labels: [
        {
          id: 1,
          name: 'bug',
        },
      ],
    }),
    partial<MockPr>({
      number: 3,
      title: 'WIP: Draft PR',
      body: 'other random pull request',
      state: 'open',
      diff_url: 'https://forgejo.renovatebot.com/some/repo/pulls/3.diff',
      created_at: '2011-08-18T22:30:39Z',
      closed_at: '2016-01-09T10:03:22Z',
      updated_at: '2017-01-09T10:03:22Z',
      mergeable: false,
      base: { ref: 'draft-base-branch' },
      head: {
        label: 'draft-head-branch',
        sha: 'draft-head-sha' as LongCommitSha,
        repo: partial<Repo>({ full_name: mockRepo.full_name }),
      },
    }),
    partial<MockPr>({
      number: 4,
      title: 'Merged PR',
      body: 'other random merged pull request',
      state: 'closed',
      diff_url: 'https://forgejo.renovatebot.com/some/repo/pulls/4.diff',
      created_at: '2011-08-18T22:30:38Z',
      closed_at: '2016-01-09T10:03:21Z',
      updated_at: '2016-01-09T10:03:21Z',
      mergeable: true,
      merged: true,
      base: { ref: 'other-base-branch' },
      head: {
        label: 'merged-head-branch',
        sha: 'merged-head-sha' as LongCommitSha,
        repo: partial<Repo>({ full_name: mockRepo.full_name }),
      },
      labels: [
        {
          id: 1,
          name: 'bug',
        },
      ],
    }),
  ];

  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(() => {
    forgejo.resetPlatform();
    memCache.init();
    repoCache.resetCache();
    git.isBranchBehindBase.mockResolvedValue(false);
    git.getBranchCommit.mockReturnValue(mockCommitHash);
    hostRules.clear();
  });

  async function initFakePlatform(
    scope: httpMock.Scope,
    version = FORGEJO_VERSION,
  ): Promise<void> {
    scope
      .get('/user')
      .reply(200, mockUser)
      .get('/version')
      .reply(200, { version });
    await forgejo.initPlatform({ token: 'abc' });
  }

  async function initFakeRepo(
    scope: httpMock.Scope,
    repo?: Partial<Repo>,
    config?: Partial<RepoParams>,
  ): Promise<void> {
    const repoResult = { ...mockRepo, ...repo };
    const repository = repoResult.full_name;
    scope.get(`/repos/${repository}`).reply(200, repoResult);
    await forgejo.initRepo({ repository, ignorePrAuthor: true, ...config });
  }

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

    it('should throw if auth fails', async () => {
      const scope = httpMock.scope('https://code.forgejo.org/api/v1');
      scope.get('/user').reply(500);

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

    it('should support default endpoint', async () => {
      const scope = httpMock.scope('https://code.forgejo.org/api/v1');
      scope
        .get('/user')
        .reply(200, mockUser)
        .get('/version')
        .reply(200, { version: FORGEJO_VERSION });

      expect(await forgejo.initPlatform({ token: 'some-token' })).toEqual({
        endpoint: 'https://code.forgejo.org/',
        gitAuthor: 'Renovate Bot <renovate@example.com>',
      });
    });

    it('should support custom endpoint', async () => {
      const scope = httpMock.scope('https://forgejo.renovatebot.com/api/v1');
      scope
        .get('/user')
        .reply(200, mockUser)
        .get('/version')
        .reply(200, { version: FORGEJO_VERSION });

      expect(
        await forgejo.initPlatform({
          token: 'some-token',
          endpoint: 'https://forgejo.renovatebot.com',
        }),
      ).toEqual({
        endpoint: 'https://forgejo.renovatebot.com/',
        gitAuthor: 'Renovate Bot <renovate@example.com>',
      });
    });

    it('should support custom endpoint including api path', async () => {
      const scope = httpMock.scope('https://forgejo.renovatebot.com/api/v1');
      scope
        .get('/user')
        .reply(200, mockUser)
        .get('/version')
        .reply(200, { version: FORGEJO_VERSION });

      expect(
        await forgejo.initPlatform({
          token: 'some-token',
          endpoint: 'https://forgejo.renovatebot.com',
        }),
      ).toEqual({
        endpoint: 'https://forgejo.renovatebot.com/',
        gitAuthor: 'Renovate Bot <renovate@example.com>',
      });
    });

    it('should use username as author name if full name is missing', async () => {
      const scope = httpMock.scope('https://code.forgejo.org/api/v1');
      scope
        .get('/user')
        .reply(200, {
          ...mockUser,
          full_name: undefined,
        })
        .get('/version')
        .reply(200, { version: FORGEJO_VERSION });

      expect(await forgejo.initPlatform({ token: 'some-token' })).toEqual({
        endpoint: 'https://code.forgejo.org/',
        gitAuthor: 'renovate <renovate@example.com>',
      });
    });
  });

  describe('getRepos', () => {
    it('should propagate any other errors from getRepos', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/search')
        .query({
          uid: 1,
          archived: false,
        })
        .replyWithError(httpMock.error('searchRepos()'));
      await initFakePlatform(scope);

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

    it('should return an array of repos', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/search')
        .query({
          uid: 1,
          archived: false,
        })
        .reply(200, {
          ok: true,
          data: mockRepos,
        });
      await initFakePlatform(scope);

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

    it('should return an filtered array of repos', async () => {
      const scope = httpMock.scope('https://code.forgejo.org/api/v1');

      scope
        .get('/repos/search')
        .query({
          uid: 1,
          archived: false,
          q: 'renovate',
          topic: true,
        })
        .reply(200, {
          ok: true,
          data: mockTopicRepos,
        });

      scope
        .get('/repos/search')
        .query({
          uid: 1,
          archived: false,
          q: 'renovatebot',
          topic: true,
        })
        .reply(200, {
          ok: true,
          data: mockTopicRepos,
        });

      await initFakePlatform(scope);

      const repos = await forgejo.getRepos({
        topics: ['renovate', 'renovatebot'],
      });
      expect(repos).toEqual(['a/b']);
    });

    it('should query the organization endpoint for each namespace', async () => {
      const scope = httpMock.scope('https://code.forgejo.org/api/v1');

      scope.get('/orgs/org1/repos').reply(200, mockNamespaceRepos);
      scope.get('/orgs/org2/repos').reply(200, mockNamespaceRepos);

      await initFakePlatform(scope);

      const repos = await forgejo.getRepos({
        namespaces: ['org1', 'org2'],
      });
      expect(repos).toEqual(['org1/repo', 'org2/repo']);
    });

    it('Sorts repos', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/search')
        .query({
          uid: 1,
          archived: false,
          sort: 'updated',
          order: 'desc',
        })
        .reply(200, {
          ok: true,
          data: mockRepos,
        });
      await initFakePlatform(scope);

      const repos = await forgejo.getRepos({
        sort: 'updated',
        order: 'desc',
      });
      expect(repos).toEqual(['a/b', 'c/d']);
    });
  });

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

    it('should propagate API errors', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get(`/repos/${initRepoCfg.repository}`)
        .replyWithError(httpMock.error('getRepo()'));
      await initFakePlatform(scope);
      await expect(forgejo.initRepo(initRepoCfg)).rejects.toThrow('getRepo()');
    });

    it('should abort when repo is archived', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get(`/repos/${initRepoCfg.repository}`)
        .reply(200, {
          ...mockRepo,
          archived: true,
        });
      await initFakePlatform(scope);
      await expect(forgejo.initRepo(initRepoCfg)).rejects.toThrow(
        REPOSITORY_ARCHIVED,
      );
    });

    it('should abort when repo is mirrored', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get(`/repos/${initRepoCfg.repository}`)
        .reply(200, {
          ...mockRepo,
          mirror: true,
        });
      await initFakePlatform(scope);
      await expect(forgejo.initRepo(initRepoCfg)).rejects.toThrow(
        REPOSITORY_MIRRORED,
      );
    });

    it('should abort when repo is empty', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get(`/repos/${initRepoCfg.repository}`)
        .reply(200, {
          ...mockRepo,
          empty: true,
        });
      await initFakePlatform(scope);
      await expect(forgejo.initRepo(initRepoCfg)).rejects.toThrow(
        REPOSITORY_EMPTY,
      );
    });

    it('should abort when repo has insufficient permissions', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get(`/repos/${initRepoCfg.repository}`)
        .reply(200, {
          ...mockRepo,
          permissions: {
            pull: false,
            push: false,
            admin: false,
          },
        });
      await initFakePlatform(scope);
      await expect(forgejo.initRepo(initRepoCfg)).rejects.toThrow(
        REPOSITORY_ACCESS_FORBIDDEN,
      );
    });

    it('should abort when repo has pulls disabled', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get(`/repos/${initRepoCfg.repository}`)
        .reply(200, {
          ...mockRepo,
          has_pull_requests: false,
        });
      await initFakePlatform(scope);
      await expect(forgejo.initRepo(initRepoCfg)).rejects.toThrow(
        REPOSITORY_BLOCKED,
      );
    });

    it('should abort when repo has no available merge methods', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get(`/repos/${initRepoCfg.repository}`)
        .reply(200, {
          ...mockRepo,
          allow_rebase: false,
        });
      await initFakePlatform(scope);
      await expect(forgejo.initRepo(initRepoCfg)).rejects.toThrow(
        REPOSITORY_BLOCKED,
      );
    });

    it('should fall back to fast-forward-only merge method', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get(`/repos/${initRepoCfg.repository}`)
        .reply(200, {
          ...mockRepo,
          allow_rebase: false,
          allow_fast_forward_only_merge: true,
          default_merge_style: 'fast-forward-only',
        });
      await initFakePlatform(scope);

      await forgejo.initRepo(initRepoCfg);

      expect(git.initRepo).toHaveBeenCalledExactlyOnceWith(
        expect.objectContaining({
          mergeMethod: 'fast-forward-only',
        }),
      );
    });

    it('should fall back to rebase-merge method', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get(`/repos/${initRepoCfg.repository}`)
        .reply(200, {
          ...mockRepo,
          allow_rebase: false,
          allow_rebase_explicit: true,
          default_merge_style: 'rebase-merge',
        });
      await initFakePlatform(scope);

      await forgejo.initRepo(initRepoCfg);

      expect(git.initRepo).toHaveBeenCalledExactlyOnceWith(
        expect.objectContaining({
          mergeMethod: 'rebase-merge',
        }),
      );
    });

    it('should fall back to squash merge method', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get(`/repos/${initRepoCfg.repository}`)
        .reply(200, {
          ...mockRepo,
          allow_rebase: false,
          allow_squash_merge: true,
          default_merge_style: 'squash',
        });
      await initFakePlatform(scope);

      await forgejo.initRepo(initRepoCfg);

      expect(git.initRepo).toHaveBeenCalledExactlyOnceWith(
        expect.objectContaining({
          mergeMethod: 'squash',
        }),
      );
    });

    it('should fall back to standard merge method', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get(`/repos/${initRepoCfg.repository}`)
        .reply(200, {
          ...mockRepo,
          allow_rebase: false,
          allow_merge_commits: true,
          default_merge_style: 'merge',
        });
      await initFakePlatform(scope);

      await forgejo.initRepo(initRepoCfg);

      expect(git.initRepo).toHaveBeenCalledExactlyOnceWith(
        expect.objectContaining({
          mergeMethod: 'merge',
        }),
      );
    });

    it('should use clone_url of repo if gitUrl is not specified', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get(`/repos/${initRepoCfg.repository}`)
        .reply(200, mockRepo);
      await initFakePlatform(scope);

      const repoCfg: RepoParams = {
        repository: mockRepo.full_name,
      };
      await forgejo.initRepo(repoCfg);

      expect(git.initRepo).toHaveBeenCalledWith(
        expect.objectContaining({ url: mockRepo.clone_url }),
      );
    });

    it('should use clone_url of repo if gitUrl has value default', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get(`/repos/${initRepoCfg.repository}`)
        .reply(200, mockRepo);
      await initFakePlatform(scope);

      const repoCfg: RepoParams = {
        repository: mockRepo.full_name,
        gitUrl: 'default',
      };
      await forgejo.initRepo(repoCfg);

      expect(git.initRepo).toHaveBeenCalledWith(
        expect.objectContaining({ url: mockRepo.clone_url }),
      );
    });

    it('should use ssh_url of repo if gitUrl has value ssh', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get(`/repos/${initRepoCfg.repository}`)
        .reply(200, mockRepo);
      await initFakePlatform(scope);

      const repoCfg: RepoParams = {
        repository: mockRepo.full_name,
        gitUrl: 'ssh',
      };
      await forgejo.initRepo(repoCfg);

      expect(git.initRepo).toHaveBeenCalledWith(
        expect.objectContaining({ url: mockRepo.ssh_url }),
      );
    });

    it('should abort when gitUrl has value ssh but ssh_url is empty', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get(`/repos/${initRepoCfg.repository}`)
        .reply(200, { ...mockRepo, ssh_url: undefined });
      await initFakePlatform(scope);

      const repoCfg: RepoParams = {
        repository: mockRepo.full_name,
        gitUrl: 'ssh',
      };

      await expect(forgejo.initRepo(repoCfg)).rejects.toThrow(
        CONFIG_GIT_URL_UNAVAILABLE,
      );
    });

    it('should use generated url of repo if gitUrl has value endpoint', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get(`/repos/${initRepoCfg.repository}`)
        .reply(200, mockRepo);
      await initFakePlatform(scope);

      const repoCfg: RepoParams = {
        repository: mockRepo.full_name,
        gitUrl: 'endpoint',
      };
      await forgejo.initRepo(repoCfg);

      expect(git.initRepo).toHaveBeenCalledWith(
        expect.objectContaining({
          url: `https://code.forgejo.org/${mockRepo.full_name}.git`,
        }),
      );
    });

    it('should abort when clone_url is empty', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get(`/repos/${initRepoCfg.repository}`)
        .reply(200, {
          ...mockRepo,
          clone_url: undefined,
        });
      await initFakePlatform(scope);

      const repoCfg: RepoParams = {
        repository: mockRepo.full_name,
      };

      await expect(forgejo.initRepo(repoCfg)).rejects.toThrow(
        CONFIG_GIT_URL_UNAVAILABLE,
      );
    });

    it('should use given access token if gitUrl has value endpoint', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get(`/repos/${initRepoCfg.repository}`)
        .reply(200, mockRepo);
      await initFakePlatform(scope);

      const token = 'abc';
      hostRules.add({
        hostType: 'forgejo',
        matchHost: 'https://code.forgejo.org/',
        token,
      });

      const repoCfg: RepoParams = {
        repository: mockRepo.full_name,
        gitUrl: 'endpoint',
      };
      await forgejo.initRepo(repoCfg);

      const url = new URL(`${mockRepo.clone_url}`);
      url.username = token;
      expect(git.initRepo).toHaveBeenCalledWith(
        expect.objectContaining({
          url: `https://${token}@code.forgejo.org/${mockRepo.full_name}.git`,
        }),
      );
    });

    it('should use given access token if gitUrl is not specified', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get(`/repos/${initRepoCfg.repository}`)
        .reply(200, mockRepo);
      await initFakePlatform(scope);

      const token = 'abc';
      hostRules.add({
        hostType: 'forgejo',
        matchHost: 'https://code.forgejo.org/',
        token,
      });

      const repoCfg: RepoParams = {
        repository: mockRepo.full_name,
      };
      await forgejo.initRepo(repoCfg);

      const url = new URL(`${mockRepo.clone_url}`);
      url.username = token;
      expect(git.initRepo).toHaveBeenCalledWith(
        expect.objectContaining({ url: url.toString() }),
      );
    });

    it('should abort when clone_url is not valid', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get(`/repos/${initRepoCfg.repository}`)
        .reply(200, {
          ...mockRepo,
          clone_url: 'abc',
        });
      await initFakePlatform(scope);

      const repoCfg: RepoParams = {
        repository: mockRepo.full_name,
      };

      await expect(forgejo.initRepo(repoCfg)).rejects.toThrow(
        CONFIG_GIT_URL_UNAVAILABLE,
      );
    });
  });

  describe('setBranchStatus', () => {
    it('should create a new commit status', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .post(
          '/repos/some/repo/statuses/0d9c7726c3d628b7e28af234595cfd20febdbf8e',
          {
            state: 'success',
            context: 'some-context',
            description: 'some-description',
          },
        )
        .reply(200)
        .get('/repos/some/repo/commits/some-branch/statuses')
        .reply(200, []);

      await initFakePlatform(scope);
      await initFakeRepo(scope);

      await expect(
        forgejo.setBranchStatus({
          branchName: 'some-branch',
          state: 'green',
          context: 'some-context',
          description: 'some-description',
        }),
      ).toResolve();
    });

    it('should default to pending state', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .post(
          '/repos/some/repo/statuses/0d9c7726c3d628b7e28af234595cfd20febdbf8e',
          {
            state: 'pending',
            context: 'some-context',
            description: 'some-description',
          },
        )
        .reply(200)
        .get('/repos/some/repo/commits/some-branch/statuses')
        .reply(200, []);

      await initFakePlatform(scope);
      await initFakeRepo(scope);

      await expect(
        forgejo.setBranchStatus({
          branchName: 'some-branch',
          context: 'some-context',
          description: 'some-description',
          state: undefined as never,
        }),
      ).toResolve();
    });

    it('should include url if specified', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .post(
          '/repos/some/repo/statuses/0d9c7726c3d628b7e28af234595cfd20febdbf8e',
          {
            state: 'success',
            context: 'some-context',
            description: 'some-description',
            target_url: 'some-url',
          },
        )
        .reply(200)
        .get('/repos/some/repo/commits/some-branch/statuses')
        .reply(200, []);

      await initFakePlatform(scope);
      await initFakeRepo(scope);

      await expect(
        forgejo.setBranchStatus({
          branchName: 'some-branch',
          state: 'green',
          context: 'some-context',
          description: 'some-description',
          url: 'some-url',
        }),
      ).toResolve();
    });

    it('should gracefully fail with warning when setting branch status', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .post(
          '/repos/some/repo/statuses/0d9c7726c3d628b7e28af234595cfd20febdbf8e',
        )
        .replyWithError('unknown error');

      await initFakePlatform(scope);
      await initFakeRepo(scope);

      await expect(
        forgejo.setBranchStatus({
          branchName: 'some-branch',
          state: 'green',
          context: 'some-context',
          description: 'some-description',
        }),
      ).toResolve();

      expect(logger.logger.warn).toHaveBeenCalledWith(
        {
          err: expect.any(Error),
        },
        'Failed to set branch status',
      );
    });
  });

  describe('getBranchStatus', () => {
    const commitStatus = (status: CommitStatusType): CommitStatus => ({
      id: 1,
      status,
      context: '',
      description: '',
      target_url: '',
      created_at: '',
    });

    it('should return yellow for unknown result', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/commits/some-branch/statuses')
        .reply(200, [commitStatus('unknown')]);
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      const res = await forgejo.getBranchStatus('some-branch', true);

      expect(res).toBe('yellow');
    });

    it('should return pending state for pending result', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/commits/some-branch/statuses')
        .reply(200, [commitStatus('pending')]);
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      const res = await forgejo.getBranchStatus('some-branch', true);

      expect(res).toBe('yellow');
    });

    it('should return green state for success result', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/commits/some-branch/statuses')
        .reply(200, [commitStatus('success')]);
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      const res = await forgejo.getBranchStatus('some-branch', true);

      expect(res).toBe('green');
    });

    it('should return yellow for all other results', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/commits/some-branch/statuses')
        .reply(200, [commitStatus('invalid' as never)]);
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      const res = await forgejo.getBranchStatus('some-branch', true);

      expect(res).toBe('yellow');
    });

    it('should abort when branch status returns 404', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/commits/some-branch/statuses')
        .reply(404);
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      await expect(
        forgejo.getBranchStatus('some-branch', true),
      ).rejects.toThrow(REPOSITORY_CHANGED);
    });

    it('should propagate any other errors from getBranchStatus', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/commits/some-branch/statuses')
        .replyWithError('unknown error');
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      await expect(
        forgejo.getBranchStatus('some-branch', true),
      ).rejects.toThrow('unknown error');
    });

    it('should treat internal checks as success', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/commits/some-branch/statuses')
        .reply(200, [
          {
            id: 1,
            status: 'success',
            context: 'renovate/stability-days',
            description: 'internal check',
            target_url: '',
            created_at: '',
          },
        ]);
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      const res = await forgejo.getBranchStatus('some-branch', true);

      expect(res).toBe('green');
    });

    it('should not treat internal checks as success', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/commits/some-branch/statuses')
        .reply(200, [
          {
            id: 1,
            status: 'success',
            context: 'renovate/stability-days',
            description: 'internal check',
            target_url: '',
            created_at: '',
          },
        ]);
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      const res = await forgejo.getBranchStatus('some-branch', false);

      expect(res).toBe('yellow');
    });
  });

  describe('getBranchStatusCheck', () => {
    it('should return null with no results', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/commits/some-branch/statuses')
        .reply(200, []);
      await initFakePlatform(scope);
      await initFakeRepo(scope);

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

    it('should return null with no matching results', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/commits/some-branch/statuses')
        .reply(200, [
          {
            id: 1,
            status: 'success',
            context: 'other-context',
            description: 'internal check',
            target_url: '',
            created_at: '',
          },
        ]);
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      const res = await forgejo.getBranchStatusCheck(
        'some-branch',
        'some-context',
      );

      expect(res).toBeNull();
    });

    it('should return yellow with unknown status', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/commits/some-branch/statuses')
        .reply(200, [
          {
            id: 1,
            status: 'xyz',
            context: 'some-context',
            description: '',
            target_url: '',
            created_at: '',
          },
        ]);
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      const res = await forgejo.getBranchStatusCheck(
        'some-branch',
        'some-context',
      );

      expect(res).toBe('yellow');
    });

    it('should return green of matching result', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/commits/some-branch/statuses')
        .reply(200, [
          {
            id: 1,
            status: 'success',
            context: 'some-context',
            description: '',
            target_url: '',
            created_at: '',
          },
        ]);
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      const res = await forgejo.getBranchStatusCheck(
        'some-branch',
        'some-context',
      );

      expect(res).toBe('green');
    });
  });

  describe('getPrList', () => {
    it('should return list of pull requests', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/pulls')
        .query({ state: 'all', sort: 'recentupdate', limit: 100 })
        .reply(200, mockPRs);
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      const res = await forgejo.getPrList();
      expect(res).toMatchObject([
        { number: 4, title: 'Merged PR' },
        { number: 3, title: 'Draft PR' },
        { number: 2, title: 'Other PR' },
        { number: 1, title: 'Some PR' },
      ]);
    });

    it('should filter list by creator', async () => {
      const thirdPartyPr = partial<PR>({
        number: 42,
        title: 'Third-party PR',
        body: 'other random pull request',
        state: 'open',
        diff_url: 'https://forgejo.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' as LongCommitSha,
          repo: partial<Repo>({ full_name: mockRepo.full_name }),
        },
        user: { username: 'not-renovate' },
      });

      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/pulls')
        .query({ state: 'all', sort: 'recentupdate', limit: 100 })
        .reply(200, [
          thirdPartyPr,
          ...mockPRs.map((pr) => ({
            ...pr,
            user: { username: 'renovate' },
          })),
        ]);
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      const res = await forgejo.getPrList();

      expect(res).toMatchObject([
        { number: 4, title: 'Merged PR' },
        { number: 3, title: 'Draft PR' },
        { number: 2, title: 'Other PR' },
        { number: 1, title: 'Some PR' },
      ]);
    });

    it('should cache results after first query', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/pulls')
        .query({
          state: 'all',
          sort: 'recentupdate',
          limit: 100,
          poster: mockUser.username,
        })
        .reply(200, mockPRs.slice(0, 2), {
          // test correct pagination handling, domain should be ignored
          link: '<https://domain.test/some/path/api/v1/repos/some/repo/pulls?state=all&sort=recentupdate&limit=50&page=2>; rel="next",<https://domain.test/some/path/api/v1/repos/some/repo/pulls?state=all&sort=recentupdate&page=4512>; rel="last"',
        })
        .get('/repos/some/repo/pulls')
        .query({ state: 'all', sort: 'recentupdate', limit: 50, page: 2 })
        .reply(200, mockPRs.slice(2));
      await initFakePlatform(scope);
      await initFakeRepo(scope, undefined, { ignorePrAuthor: false });

      const res1 = await forgejo.getPrList();
      const res2 = await forgejo.getPrList();

      expect(res1).toEqual(res2);
    });

    it('should update cache results', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/pulls')
        .query({ state: 'all', sort: 'recentupdate', limit: 100 })
        .reply(200, mockPRs.slice(0, 2))
        .get('/repos/some/repo/pulls')
        .query({ state: 'all', sort: 'recentupdate', limit: 20 })
        .reply(200, mockPRs.slice(1));
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      const res1 = await forgejo.getPrList();
      expect(res1).toMatchObject([{ number: 2 }, { number: 1 }]);

      memCache.set('forgejo-pr-cache-synced', false);

      const res2 = await forgejo.getPrList();
      expect(res2).toMatchObject([
        { number: 4 },
        { number: 3 },
        { number: 2 },
        { number: 1 },
      ]);
    });
  });

  describe('getPr', () => {
    it('should return enriched pull request which exists if open', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/pulls')
        .query({ state: 'all', sort: 'recentupdate', limit: 100 })
        .reply(200, mockPRs);
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      const res = await forgejo.getPr(1);

      expect(res).toMatchObject({ number: 1, title: 'Some PR' });
    });

    it('should fallback to direct fetching if cache fails', async () => {
      const pr = mockPRs.find((pr) => pr.number === 1);
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/pulls')
        .query({ state: 'all', sort: 'recentupdate', limit: 100 })
        .reply(200, [])
        .get('/repos/some/repo/pulls/1')
        .reply(200, pr);
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      const res = await forgejo.getPr(1);

      expect(res).toMatchObject({ number: 1, title: 'Some PR' });
    });

    it('should return null for missing pull request in getPr', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/pulls')
        .query({ state: 'all', sort: 'recentupdate', limit: 100 })
        .reply(200, [])
        .get('/repos/some/repo/pulls/42')
        .reply(200); // TODO: 404 should be handled
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      const res = await forgejo.getPr(42);

      expect(res).toBeNull();
    });

    it('should throw temporary error for null pull request', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/pulls')
        .query({ state: 'all', sort: 'recentupdate', limit: 100 })
        .reply(200, [null]); // TODO: 404 should be handled
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      await expect(forgejo.getPr(42)).rejects.toThrow(TEMPORARY_ERROR);
    });
  });

  describe('findPr', () => {
    it('should find pull request without title or state', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/pulls')
        .query({ state: 'all', sort: 'recentupdate', limit: 100 })
        .reply(200, mockPRs);
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      const res = await forgejo.findPr({ branchName: 'some-head-branch' });

      expect(res).toMatchObject({
        number: 1,
        sourceBranch: 'some-head-branch',
      });
    });

    it('should find pull request with title', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/pulls')
        .query({ state: 'all', sort: 'recentupdate', limit: 100 })
        .reply(200, mockPRs);
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      const res = await forgejo.findPr({
        branchName: 'some-head-branch',
        prTitle: 'Some PR',
      });

      expect(res).toMatchObject({
        number: 1,
        title: 'Some PR',
      });
    });

    it('should find pull request with state', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/pulls')
        .query({ state: 'all', sort: 'recentupdate', limit: 100 })
        .reply(200, mockPRs);
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      const res = await forgejo.findPr({
        branchName: 'some-head-branch',
        state: 'open',
      });

      expect(res).toMatchObject({
        number: 1,
        state: 'open',
      });
    });

    it('should not find pull request with inverted state', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/pulls')
        .query({ state: 'all', sort: 'recentupdate', limit: 100 })
        .reply(200, mockPRs);
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      const res = await forgejo.findPr({
        branchName: 'other-head-branch',
        state: `!open`,
      });

      expect(res).toMatchObject({
        number: 2,
        state: 'closed',
        title: 'Other PR',
      });
    });

    it('should find pull request with title and state', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/pulls')
        .query({ state: 'all', sort: 'recentupdate', limit: 100 })
        .reply(200, mockPRs);
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      const res = await forgejo.findPr({
        branchName: 'other-head-branch',
        prTitle: 'Other PR',
        state: 'closed',
      });

      expect(res).toMatchObject({
        number: 2,
        state: 'closed',
        title: 'Other PR',
      });
    });

    it('should find pull request with draft', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/pulls')
        .query({ state: 'all', sort: 'recentupdate', limit: 100 })
        .reply(200, mockPRs);
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      const res = await forgejo.findPr({
        branchName: 'draft-head-branch',
        prTitle: 'Draft PR',
        state: 'open',
      });

      expect(res).toMatchObject({
        number: 3,
        title: 'Draft PR',
        isDraft: true,
      });
    });

    it('should find merged pull request', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/pulls')
        .query({ state: 'all', sort: 'recentupdate', limit: 100 })
        .reply(200, mockPRs);
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      const res = await forgejo.findPr({ branchName: 'merged-head-branch' });

      expect(res).toMatchObject({
        number: 4,
        sourceBranch: 'merged-head-branch',
        state: 'merged',
      });
    });

    it('should return null for missing pull request in findPr', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/pulls')
        .query({ state: 'all', sort: 'recentupdate', limit: 100 })
        .reply(200, mockPRs);
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      const res = await forgejo.findPr({ branchName: 'missing' });

      expect(res).toBeNull();
    });

    it('finds pr from other authors using base and head', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/pulls/some-base-branch/some-head-branch')
        .reply(200, {
          number: 42,
          state: 'open',
          head: {
            label: 'some-head-branch',
            sha: mockCommitHash,
            repo: partial<Repo>({ full_name: mockRepo.full_name }),
          },
          base: {
            ref: 'some-base-branch',
          },
          title: 'from other author',
          body: 'pr-body',
          mergeable: true,
          created_at: '2014-04-01T05:14:20Z',
          updated_at: '2017-12-28T12:17:48Z',
        });
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      const res = await forgejo.findPr({
        branchName: 'some-head-branch',
        targetBranch: 'some-base-branch',
        includeOtherAuthors: true,
      });

      expect(res).toMatchObject({
        number: 42,
        sourceBranch: 'some-head-branch',
        targetBranch: 'some-base-branch',
        title: 'from other author',
      });
    });

    it('returns null if cannot find pr from other author', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/pulls/some-base-branch/some-head-branch')
        .reply(404);
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      const res = await forgejo.findPr({
        branchName: 'some-head-branch',
        targetBranch: 'some-base-branch',
        includeOtherAuthors: true,
      });

      expect(res).toBeNull();
    });
  });

  describe('createPr', () => {
    beforeEach(() => {
      vi.restoreAllMocks();
      memCache.set('forgejo-pr-cache-synced', true);
    });

    const mockNewPR: MockPr = {
      number: 42,
      state: 'open',
      head: {
        label: 'pr-branch',
        sha: mockCommitHash,
        repo: partial<Repo>({ full_name: mockRepo.full_name }),
      },
      base: {
        ref: mockRepo.default_branch,
      },
      diff_url: 'https://forgejo.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',
      updated_at: '2017-12-28T12:17:48Z',
    };

    it('should use base branch by default', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .post('/repos/some/repo/pulls')
        .reply(200, {
          ...mockNewPR,
          base: { ref: 'devel' },
        });
      await initFakePlatform(scope);
      await initFakeRepo(scope);

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

      expect(res).toMatchObject({
        number: 42,
        title: 'pr-title',
      });
    });

    it('should use default branch if requested', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .post('/repos/some/repo/pulls')
        .reply(200, mockNewPR);
      await initFakePlatform(scope);
      await initFakeRepo(scope);

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

      expect(res).toMatchObject({
        number: 42,
        title: 'pr-title',
      });
    });

    it('should resolve and apply optional labels to pull request', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .post('/repos/some/repo/pulls')
        .reply(200, mockNewPR)
        .get('/repos/some/repo/labels')
        .reply(200, mockRepoLabels)
        .get('/orgs/some/labels')
        .reply(200, mockOrgLabels);
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      const res = await forgejo.createPr({
        sourceBranch: mockNewPR.head.label,
        targetBranch: 'master',
        prTitle: mockNewPR.title,
        prBody: mockNewPR.body,
        labels: [...mockRepoLabels, ...mockOrgLabels].map(({ name }) => name),
      });

      expect(res).toMatchObject({
        number: 42,
        title: 'pr-title',
      });
    });

    it('should ensure new pull request gets added to cached pull requests', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .post('/repos/some/repo/pulls')
        .reply(200, mockNewPR);
      await initFakePlatform(scope);
      await initFakeRepo(scope);

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

      expect(res).toMatchObject({
        number: 42,
        title: 'pr-title',
      });
    });

    it('should attempt to resolve 409 conflict error (w/o update)', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .post('/repos/some/repo/pulls')
        .reply(409)
        .get('/repos/some/repo/pulls')
        .query({ state: 'all', sort: 'recentupdate', limit: 100 })
        .reply(200, [mockNewPR]);
      await initFakePlatform(scope);
      await initFakeRepo(scope);

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

      expect(res).toMatchObject({
        number: 42,
        title: 'pr-title',
      });
    });

    it('should attempt to resolve 409 conflict error (w/ update)', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .post('/repos/some/repo/pulls')
        .reply(409)
        .get('/repos/some/repo/pulls')
        .query({ state: 'all', sort: 'recentupdate', limit: 100 })
        .reply(200, [mockNewPR])
        .patch('/repos/some/repo/pulls/42')
        .reply(200);
      await initFakePlatform(scope);
      await initFakeRepo(scope);

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

      expect(res).toMatchObject({
        number: 42,
        title: 'new-title',
      });
    });

    it('should abort when response for created pull request is invalid', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .post('/repos/some/repo/pulls')
        .reply(200, {});
      await initFakePlatform(scope);
      await initFakeRepo(scope);

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

    it('should use platform automerge', async () => {
      const mergePR = vi.spyOn(helper, 'mergePR');
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .post('/repos/some/repo/pulls')
        .reply(200, mockNewPR)
        .post('/repos/some/repo/pulls/42/merge')
        .reply(200);
      await initFakePlatform(scope, '10.0.0-13-c504062+gitea-1.22.0');
      await initFakeRepo(scope);

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

      expect(res).toMatchObject({
        number: 42,
        title: 'pr-title',
      });
      expect(mergePR).toHaveBeenCalled();
    });

    it('should not use platform automerge on forgejo v7', async () => {
      const mergePR = vi.spyOn(helper, 'mergePR');
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .post('/repos/some/repo/pulls')
        .reply(200, mockNewPR);
      await initFakePlatform(scope, '7.0.0-dev-2136-f075579c95+forgejo-1.22.0');
      await initFakeRepo(scope);

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

      expect(res).toMatchObject({
        number: 42,
        title: 'pr-title',
      });
      expect(mergePR).not.toHaveBeenCalled();
    });

    it('should not use platform automerge on forgejo v7 LTS', async () => {
      const mergePR = vi.spyOn(helper, 'mergePR');
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .post('/repos/some/repo/pulls')
        .reply(200, mockNewPR);
      await initFakePlatform(scope, '7.0.0+LTS-forgejo-1.22.0');
      await initFakeRepo(scope);

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

      expect(res).toMatchObject({
        number: 42,
        title: 'pr-title',
      });
      expect(mergePR).not.toHaveBeenCalled();
    });

    it('continues on platform automerge error', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .post('/repos/some/repo/pulls')
        .reply(200, mockNewPR)
        .post('/repos/some/repo/pulls/42/merge')
        .replyWithError('unknown error');
      await initFakePlatform(scope);
      await initFakeRepo(scope);

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

      expect(res).toMatchObject({
        number: 42,
        title: 'pr-title',
      });
      expect(logger.logger.warn).toHaveBeenCalledWith(
        expect.objectContaining({ prNumber: 42 }),
        'Forgejo-native automerge: fail',
      );
    });

    it('continues if platform automerge is not supported', async () => {
      memCache.set('forgejo-pr-cache-synced', true);
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .post('/repos/some/repo/pulls')
        .reply(200, mockNewPR);
      await initFakePlatform(scope, '1.10.0');
      await initFakeRepo(scope);

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

      expect(res).toMatchObject({
        number: 42,
        title: 'pr-title',
      });
      expect(logger.logger.debug).toHaveBeenCalledWith(
        expect.objectContaining({ prNumber: 42 }),
        'Forgejo-native automerge: not supported on this version of Forgejo. Use 10.0.0 or newer.',
      );
    });

    it('should create PR with repository merge method when automergeStrategy is auto', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .post('/repos/some/repo/pulls')
        .reply(200, mockNewPR)
        .post('/repos/some/repo/pulls/42/merge')
        .reply(200);
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      const res = await forgejo.createPr({
        sourceBranch: mockNewPR.head.label,
        targetBranch: 'master',
        prTitle: mockNewPR.title,
        prBody: mockNewPR.body,
        platformPrOptions: {
          automergeStrategy: 'auto',
          usePlatformAutomerge: true,
        },
      });

      expect(res).toMatchObject({
        number: 42,
        title: 'pr-title',
      });
    });

    it.each`
      automergeStrategy | prMergeStrategy
      ${'fast-forward'} | ${'rebase'}
      ${'merge-commit'} | ${'merge'}
      ${'rebase'}       | ${'rebase-merge'}
      ${'squash'}       | ${'squash'}
    `(
      'should create PR with mergeStrategy $prMergeStrategy',
      async ({ automergeStrategy, prMergeStrategy }) => {
        const scope = httpMock
          .scope('https://code.forgejo.org/api/v1')
          .post('/repos/some/repo/pulls')
          .reply(200, mockNewPR)
          .post('/repos/some/repo/pulls/42/merge')
          .reply(200, {
            Do: prMergeStrategy,
            merge_when_checks_succeed: true,
          });
        await initFakePlatform(scope);
        await initFakeRepo(scope);

        const res = await forgejo.createPr({
          sourceBranch: mockNewPR.head.label,
          targetBranch: 'master',
          prTitle: mockNewPR.title,
          prBody: mockNewPR.body,
          platformPrOptions: {
            automergeStrategy,
            usePlatformAutomerge: true,
          },
        });

        expect(res).toMatchObject({
          number: 42,
          title: 'pr-title',
        });
      },
    );
  });

  describe('updatePr', () => {
    it('should update pull request with title', async () => {
      const pr = mockPRs.find((pr) => pr.number === 1);
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/pulls')
        .query({ state: 'all', sort: 'recentupdate', limit: 100 })
        .reply(200, mockPRs)
        .patch('/repos/some/repo/pulls/1', { title: 'New Title' })
        .reply(200, pr);
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      await expect(
        forgejo.updatePr({ number: 1, prTitle: 'New Title' }),
      ).toResolve();
    });

    it('should update pull target branch', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/pulls')
        .query({ state: 'all', sort: 'recentupdate', limit: 100 })
        .reply(200, mockPRs)
        .patch('/repos/some/repo/pulls/1', {
          title: 'New Title',
          base: 'New Base',
        })
        .reply(200);
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      await expect(
        forgejo.updatePr({
          number: 1,
          prTitle: 'New Title',
          targetBranch: 'New Base',
        }),
      ).toResolve();
    });

    it('should update pull request with title and body', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/pulls')
        .query({ state: 'all', sort: 'recentupdate', limit: 100 })
        .reply(200, mockPRs)
        .patch('/repos/some/repo/pulls/1', {
          title: 'New Title',
          body: 'New Body',
        })
        .reply(200);
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      await expect(
        forgejo.updatePr({
          number: 1,
          prTitle: 'New Title',
          prBody: 'New Body',
        }),
      ).toResolve();
    });

    it('should update pull request with draft', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/pulls')
        .query({ state: 'all', sort: 'recentupdate', limit: 100 })
        .reply(200, mockPRs)
        .patch('/repos/some/repo/pulls/3', {
          title: 'WIP: New Title',
          body: 'New Body',
        })
        .reply(200);
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      await expect(
        forgejo.updatePr({
          number: 3,
          prTitle: 'New Title',
          prBody: 'New Body',
        }),
      ).toResolve();
    });

    it('should close pull request', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/pulls')
        .query({ state: 'all', sort: 'recentupdate', limit: 100 })
        .reply(200, mockPRs)
        .patch('/repos/some/repo/pulls/1', {
          title: 'New Title',
          body: 'New Body',
          state: 'closed',
        })
        .reply(200);
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      await expect(
        forgejo.updatePr({
          number: 1,
          prTitle: 'New Title',
          prBody: 'New Body',
          state: 'closed',
        }),
      ).toResolve();
    });

    it('should update labels', async () => {
      const updatedMockPR = partial<PR>({
        ...mockPRs[0],
        number: 1,
        title: 'New Title',
        body: 'New Body',
        state: 'open',
        labels: [
          {
            id: 1,
            name: 'some-label',
          },
        ],
      });
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/pulls')
        .query({ state: 'all', sort: 'recentupdate', limit: 100 })
        .reply(200, mockPRs)
        .get('/repos/some/repo/labels')
        .reply(200, mockRepoLabels)
        .get('/orgs/some/labels')
        .reply(200, mockOrgLabels)
        .patch('/repos/some/repo/pulls/1')
        .reply(200, updatedMockPR);

      await initFakePlatform(scope);
      await initFakeRepo(scope);
      await expect(
        forgejo.updatePr({
          number: 1,
          prTitle: 'New Title',
          prBody: 'New Body',
          state: 'open',
          labels: ['some-label'],
        }),
      ).toResolve();
    });

    it('should log a warning if labels could not be looked up', async () => {
      const updatedMockPR = partial<PR>({
        ...mockPRs[0],
        number: 1,
        title: 'New Title',
        body: 'New Body',
        state: 'open',
        labels: [
          {
            id: 1,
            name: 'some-label',
          },
        ],
      });
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/pulls')
        .query({ state: 'all', sort: 'recentupdate', limit: 100 })
        .reply(200, mockPRs)
        .get('/repos/some/repo/labels')
        .reply(200, mockRepoLabels)
        .get('/orgs/some/labels')
        .reply(200, mockOrgLabels)
        .patch('/repos/some/repo/pulls/1')
        .reply(200, updatedMockPR);

      await initFakePlatform(scope);
      await initFakeRepo(scope);
      await expect(
        forgejo.updatePr({
          number: 1,
          prTitle: 'New Title',
          prBody: 'New Body',
          state: 'open',
          labels: ['some-label', 'unavailable-label'],
        }),
      ).toResolve();
      expect(logger.logger.warn).toHaveBeenCalledWith(
        'Some labels could not be looked up. Renovate may halt label updates assuming changes by others.',
      );
    });
  });

  describe('mergePr', () => {
    it('should return true when merging succeeds', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .post('/repos/some/repo/pulls/1/merge', {
          Do: 'rebase',
        })
        .reply(200);
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      const res = await forgejo.mergePr({
        branchName: 'some-branch',
        id: 1,
      });

      expect(res).toBe(true);
    });

    it('should return false when merging fails', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .post('/repos/some/repo/pulls/1/merge', {
          Do: 'squash',
        })
        .replyWithError('unknown');
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      const res = await forgejo.mergePr({
        branchName: 'some-branch',
        id: 1,
        strategy: 'squash',
      });

      expect(res).toBe(false);
    });
  });

  describe('getIssueList', () => {
    it('should return empty for disabled issues', async () => {
      const scope = httpMock.scope('https://code.forgejo.org/api/v1');
      await initFakePlatform(scope);
      await initFakeRepo(scope, { has_issues: false });

      const res = await forgejo.getIssueList();

      expect(res).toBeEmptyArray();
    });
  });

  describe('getIssue', () => {
    it('should return the issue', async () => {
      const mockIssue = mockIssues.find((i) => i.number === 1)!;
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/issues/1')
        .reply(200, mockIssue);
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      const res = await forgejo.getIssue?.(mockIssue.number);

      expect(res).toEqual({
        body: 'some-content',
        number: 1,
      });
    });

    it('should return null for disabled issues in getIssue', async () => {
      const scope = httpMock.scope('https://code.forgejo.org/api/v1');
      await initFakePlatform(scope);
      await initFakeRepo(scope, { has_issues: false });

      const res = await forgejo.getIssue!(1);

      expect(res).toBeNull();
    });
  });

  describe('findIssue', () => {
    it('should return existing open issue', async () => {
      const mockIssue = mockIssues.find(({ title }) => title === 'open-issue')!;
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/issues')
        .query({ state: 'all', type: 'issues' })
        .reply(200, mockIssues)
        .get('/repos/some/repo/issues/1')
        .reply(200, mockIssue);
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      const res = await forgejo.findIssue(mockIssue.title);

      expect(res).toMatchObject({
        body: 'some-content',
        number: 1,
      });
    });

    it('should not return existing closed issue', async () => {
      const mockIssue = mockIssues.find(
        ({ title }) => title === 'closed-issue',
      )!;
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/issues')
        .query({ state: 'all', type: 'issues' })
        .reply(200, mockIssues);
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      const res = await forgejo.findIssue(mockIssue.title);

      expect(res).toBeNull();
    });

    it('should return null for missing issue', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/issues')
        .query({ state: 'all', type: 'issues' })
        .reply(200, mockIssues);
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      const res = await forgejo.findIssue('missing');

      expect(res).toBeNull();
    });
  });

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

      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/issues')
        .query({ state: 'all', type: 'issues' })
        .reply(200, mockIssues)
        .post('/repos/some/repo/issues', {
          body: mockIssue.body,
          title: mockIssue.title,
        })
        .reply(200, { number: 42 });
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      const res = await forgejo.ensureIssue(mockIssue);

      expect(res).toBe('created');
    });

    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 scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/issues')
        .query({ state: 'all', type: 'issues' })
        .reply(200, mockIssues)
        .get('/repos/some/repo/labels')
        .reply(200, [
          partial<Label>({ id: 1, name: 'Renovate' }),
          partial<Label>({ id: 3, name: 'Maintenance' }),
        ] satisfies Label[])
        .get('/orgs/some/labels')
        .reply(200, mockOrgLabels)
        .post('/repos/some/repo/issues', {
          body: 'new-body',
          title: 'new-title',
          labels: [1, 3],
        })
        .reply(200, { number: 42 });
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      const res = await forgejo.ensureIssue(mockIssue);

      expect(res).toBe('created');
    });

    it('should not reopen closed issue by default', async () => {
      const closedIssue = mockIssues.find((i) => i.title === 'closed-issue')!;
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/issues')
        .query({ state: 'all', type: 'issues' })
        .reply(200, mockIssues)
        .patch('/repos/some/repo/issues/2', {
          body: closedIssue.body,
          state: closedIssue.state,
          title: 'closed-issue',
        })
        .reply(200, closedIssue);
      await initFakePlatform(scope);
      await initFakeRepo(scope);

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

      expect(res).toBe('updated');
    });

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

      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/issues')
        .query({ state: 'all', type: 'issues' })
        .reply(200, [mockIssue])
        .patch('/repos/some/repo/issues/10')
        .reply(200, mockIssue)
        .get('/repos/some/repo/labels')
        .reply(200, mockLabels)
        .get('/orgs/some/labels')
        .reply(200, []);
      await initFakePlatform(scope);
      await initFakeRepo(scope);

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

      expect(res).toBe('updated');
    });

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

      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/issues')
        .query({ state: 'all', type: 'issues' })
        .reply(200, [mockIssue])
        .patch('/repos/some/repo/issues/10')
        .reply(200, mockIssue)
        .get('/repos/some/repo/labels')
        .reply(200, mockLabels)
        .get('/orgs/some/labels')
        .reply(200, [])
        .put('/repos/some/repo/issues/10/labels', { labels: [1, 3] })
        .reply(200, mockLabels);
      await initFakePlatform(scope);
      await initFakeRepo(scope);

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

      expect(res).toBe('updated');
    });

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

      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/issues')
        .query({ state: 'all', type: 'issues' })
        .reply(200, [mockIssue])
        .patch('/repos/some/repo/issues/10')
        .reply(200, mockIssue)
        .get('/repos/some/repo/labels')
        .reply(200, mockLabels)
        .get('/orgs/some/labels')
        .reply(200, [])
        .put('/repos/some/repo/issues/10/labels', { labels: [1, 3] })
        .reply(200, mockLabels);
      await initFakePlatform(scope);
      await initFakeRepo(scope);

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

      expect(res).toBe('updated');
    });

    it('should reopen closed issue if desired', async () => {
      const closedIssue = mockIssues.find((i) => i.title === 'closed-issue')!;
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/issues')
        .query({ state: 'all', type: 'issues' })
        .reply(200, mockIssues)
        .patch('/repos/some/repo/issues/2', {
          body: closedIssue.body,
          state: 'open',
          title: 'closed-issue',
        })
        .reply(200, { ...closedIssue, state: 'open' });
      await initFakePlatform(scope);
      await initFakeRepo(scope);

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

      expect(res).toBe('updated');
    });

    it('should not update existing closed issue if desired', async () => {
      const closedIssue = mockIssues.find((i) => i.title === 'closed-issue')!;
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/issues')
        .query({ state: 'all', type: 'issues' })
        .reply(200, mockIssues);
      await initFakePlatform(scope);
      await initFakeRepo(scope);

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

      expect(res).toBeNull();
    });

    it('should close all open duplicate issues except first one when updating', async () => {
      const duplicates = mockIssues.filter(
        (i) => i.title === 'duplicate-issue',
      );
      const [first, second, third] = duplicates;
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/issues')
        .query({ state: 'all', type: 'issues' })
        .reply(200, duplicates)
        .patch(`/repos/some/repo/issues/${second.number}`, {
          state: 'closed',
        })
        .reply(200, { ...second, state: 'closed' })
        .patch(`/repos/some/repo/issues/${third.number}`, {
          state: 'closed',
        })
        .reply(200, { ...third, state: 'closed' });
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      const res = await forgejo.ensureIssue({
        title: first.title,
        body: first.body,
        shouldReOpen: false,
        once: false,
      });

      expect(res).toBeNull();
    });

    it('should reset issue cache when creating an issue', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/issues')
        .query({ state: 'all', type: 'issues' })
        .twice()
        .reply(200, mockIssues)
        .post('/repos/some/repo/issues')
        .reply(200, { number: 42 });
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      await expect(
        forgejo.ensureIssue({
          title: 'new-title',
          body: 'new-body',
          shouldReOpen: false,
          once: false,
        }),
      ).resolves.toBe('created');

      await expect(forgejo.getIssueList()).toResolve();
    });

    it('should gracefully fail with warning when ensuring issue', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/issues')
        .query({ state: 'all', type: 'issues' })
        .replyWithError('unknown');
      await initFakePlatform(scope);
      await initFakeRepo(scope);

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

      expect(logger.logger.warn).toHaveBeenCalledWith(
        { err: expect.any(Error) },
        'Could not ensure issue',
      );
    });

    it('should return null for disabled issues in ensureIssue', async () => {
      const scope = httpMock.scope('https://code.forgejo.org/api/v1');
      await initFakePlatform(scope);
      await initFakeRepo(scope, { has_issues: false });

      await expect(
        forgejo.ensureIssue({
          title: 'new-title',
          body: 'new-body',
          shouldReOpen: false,
          once: false,
        }),
      ).resolves.toBeNull();
    });
  });

  describe('ensureIssueClosing', () => {
    it('should close issues with matching title', async () => {
      const mockIssue = mockIssues[0];
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/issues')
        .query({ state: 'all', type: 'issues' })
        .reply(200, mockIssues)
        .patch('/repos/some/repo/issues/1', { state: 'closed' })
        .reply(200, { ...mockIssue, state: 'closed' });
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      await expect(forgejo.ensureIssueClosing(mockIssue.title)).toResolve();
    });

    it('should return for disabled issues', async () => {
      const scope = httpMock.scope('https://code.forgejo.org/api/v1');
      await initFakePlatform(scope);
      await initFakeRepo(scope, { has_issues: false });
      await expect(forgejo.ensureIssueClosing('new-title')).toResolve();
    });
  });

  describe('deleteLabel', () => {
    it('should delete a label which exists', async () => {
      const mockLabel = mockRepoLabels[0];
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/orgs/some/labels')
        .replyWithError('unknown')
        .get('/repos/some/repo/labels')
        .reply(200, mockRepoLabels)
        .delete(`/repos/some/repo/issues/42/labels/${mockLabel.id}`)
        .reply(200);
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      await expect(forgejo.deleteLabel(42, mockLabel.name)).toResolve();
    });

    it('should gracefully fail with warning if label is missing', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/orgs/some/labels')
        .reply(200, [])
        .get('/repos/some/repo/labels')
        .reply(200, mockRepoLabels);
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      await expect(forgejo.deleteLabel(42, 'missing')).toResolve();

      expect(logger.logger.warn).toHaveBeenCalledWith(
        { issue: 42, labelName: 'missing' },
        'Failed to lookup label for deletion',
      );
    });
  });

  describe('ensureComment', () => {
    it('should add comment with topic if not found', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/issues/1/comments')
        .reply(200, mockComments)
        .post('/repos/some/repo/issues/1/comments', {
          body: '### other-topic\n\nother-content',
        })
        .reply(200);
      await initFakePlatform(scope);
      await initFakeRepo(scope);

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

      expect(res).toBeTrue();
    });

    it('should add comment without topic if not found', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/issues/1/comments')
        .reply(200, mockComments)
        .post('/repos/some/repo/issues/1/comments', { body: 'other-content' })
        .reply(200);
      await initFakePlatform(scope);
      await initFakeRepo(scope);

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

      expect(res).toBeTrue();
    });

    it('should update comment with topic if found', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/issues/1/comments')
        .reply(200, mockComments)
        .patch('/repos/some/repo/issues/comments/13', {
          body: '### some-topic\n\nsome-new-content',
        })
        .reply(200, partial<Comment>({ id: 13 }));
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      const res = await forgejo.ensureComment({
        number: 1,
        topic: 'some-topic',
        content: 'some-new-content',
      });

      expect(res).toBeTrue();
    });

    it('should skip if comment is up-to-date', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/issues/1/comments')
        .reply(200, mockComments);
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      const res = await forgejo.ensureComment({
        number: 1,
        topic: 'some-topic',
        content: 'some-content',
      });

      expect(res).toBeTrue();
    });

    it('should gracefully fail with warning when ensuring comment', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/issues/1/comments')
        .replyWithError('unknown');
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      const res = await forgejo.ensureComment({
        number: 1,
        topic: 'some-topic',
        content: 'some-content',
      });

      expect(res).toBe(false);
      expect(logger.logger.warn).toHaveBeenCalledWith(
        { err: expect.any(Error), issue: 1, subject: 'some-topic' },
        'Error ensuring comment',
      );
    });
  });

  describe('ensureCommentRemoval', () => {
    it('should remove existing comment by topic', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/issues/1/comments')
        .reply(200, mockComments)
        .delete('/repos/some/repo/issues/comments/13')
        .reply(200);
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      await expect(
        forgejo.ensureCommentRemoval({
          type: 'by-topic',
          number: 1,
          topic: 'some-topic',
        }),
      ).toResolve();
    });

    it('should remove existing comment by content', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/issues/1/comments')
        .reply(200, mockComments)
        .delete('/repos/some/repo/issues/comments/11')
        .reply(200);
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      await expect(
        forgejo.ensureCommentRemoval({
          type: 'by-content',
          number: 1,
          content: 'some-body',
        }),
      ).toResolve();
    });

    it('should gracefully fail with warning when removing comment', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/issues/1/comments')
        .reply(200, mockComments)
        .delete('/repos/some/repo/issues/comments/13')
        .replyWithError('unknown');
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      await forgejo.ensureCommentRemoval({
        type: 'by-topic',
        number: 1,
        topic: 'some-topic',
      });

      expect(logger.logger.warn).toHaveBeenCalledWith(
        {
          config: { number: 1, topic: 'some-topic', type: 'by-topic' },
          err: expect.any(Error),
          issue: 1,
        },
        'Error deleting comment',
      );
    });

    it('should abort silently if comment is missing', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/issues/1/comments')
        .reply(200, mockComments);
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      await expect(
        forgejo.ensureCommentRemoval({
          type: 'by-topic',
          number: 1,
          topic: 'missing',
        }),
      ).toResolve();
    });
  });

  describe('getBranchPr', () => {
    it('should return existing pull request for branch', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/pulls')
        .query({ state: 'all', sort: 'recentupdate', limit: 100 })
        .reply(200, mockPRs);
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      const res = await forgejo.getBranchPr('some-head-branch');

      expect(res).toMatchObject({ number: 1 });
    });

    it('should return null if no pull request exists', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/pulls')
        .query({ state: 'all', sort: 'recentupdate', limit: 100 })
        .reply(200, mockPRs);
      await initFakePlatform(scope);
      await initFakeRepo(scope);

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

  describe('addAssignees', () => {
    it('should add assignees to the issue', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .patch('/repos/some/repo/issues/1', {
          assignees: ['me', 'you'],
        })
        .reply(200);
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      await expect(forgejo.addAssignees(1, ['me', 'you'])).toResolve();
    });
  });

  describe('addReviewers', () => {
    it('should assign reviewers', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .post('/repos/some/repo/pulls/1/requested_reviewers', {
          reviewers: ['me', 'you'],
          team_reviewers: ['org/team'],
        })
        .reply(200);
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      await expect(
        forgejo.addReviewers(1, ['me', 'you', 'org/team']),
      ).toResolve();

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

    it('catches errors', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .post('/repos/some/repo/pulls/1/requested_reviewers', {
          reviewers: ['me', 'you'],
        })
        .replyWithError('unknown');
      await initFakePlatform(scope);
      await initFakeRepo(scope);
      ///
      await expect(forgejo.addReviewers(1, ['me', 'you'])).toResolve();
      expect(logger.logger.warn).toHaveBeenCalledWith(
        { err: expect.any(Error), number: 1, reviewers: ['me', 'you'] },
        'Failed to assign reviewer',
      );
    });
  });

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

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

  it('maxBodyLength', () => {
    expect(forgejo.maxBodyLength()).toBe(1000000);
  });

  describe('getJsonFile()', () => {
    it('returns file content', async () => {
      const data = { foo: 'bar' };
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/contents/file.json')
        .reply(200, {
          content: Buffer.from(JSON.stringify(data), 'utf-8'),
        });
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      const res = await forgejo.getJsonFile('file.json');

      expect(res).toEqual(data);
    });

    it('returns file content from given repo', async () => {
      const data = { foo: 'bar' };
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/different/repo/contents/file.json')
        .reply(200, {
          content: Buffer.from(JSON.stringify(data), 'utf-8'),
        });
      await initFakePlatform(scope);
      await initFakeRepo(scope, { full_name: 'different/repo' });

      const res = await forgejo.getJsonFile('file.json', 'different/repo');

      expect(res).toEqual(data);
    });

    it('returns file content from branch or tag', async () => {
      const data = { foo: 'bar' };
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/contents/file.json?ref=dev')
        .reply(200, {
          content: Buffer.from(JSON.stringify(data), 'utf-8'),
        });
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      const res = await forgejo.getJsonFile('file.json', 'some/repo', 'dev');

      expect(res).toEqual(data);
    });

    it('returns file content in json5 format', async () => {
      const json5Data = `
        {
          // json5 comment
          foo: 'bar'
        }
      `;
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/contents/file.json5')
        .reply(200, {
          content: Buffer.from(json5Data, 'utf-8'),
        });
      await initFakePlatform(scope);
      await initFakeRepo(scope);

      const res = await forgejo.getJsonFile('file.json5');

      expect(res).toEqual({ foo: 'bar' });
    });

    it('throws on malformed JSON', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/contents/file.json')
        .reply(200, {
          content: Buffer.from('!@#', 'utf-8'),
        });
      await initFakePlatform(scope);
      await initFakeRepo(scope);
      await expect(forgejo.getJsonFile('file.json')).rejects.toThrow();
    });

    it('returns null on missing content', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/contents/file.json')
        .reply(200, {});
      await initFakePlatform(scope);
      await initFakeRepo(scope);
      expect(await forgejo.getJsonFile('file.json')).toBeNull();
    });

    it('throws on errors', async () => {
      const scope = httpMock
        .scope('https://code.forgejo.org/api/v1')
        .get('/repos/some/repo/contents/file.json')
        .replyWithError('unknown');
      await initFakePlatform(scope);
      await initFakeRepo(scope);
      await expect(forgejo.getJsonFile('file.json')).rejects.toThrow();
    });
  });
});
