From c9357cc340123a13d8e8b47edb1645816ef0c76b Mon Sep 17 00:00:00 2001
From: Pascal Mathis <github@ppmathis.com>
Date: Wed, 19 Feb 2020 11:19:25 +0100
Subject: [PATCH] feat: add support for gitea platform (#5509)

---
 docs/development/self-hosting.md              |    6 +
 docs/usage/modules/platform.md                |    1 +
 lib/constants/platforms.ts                    |    1 +
 lib/platform/common.ts                        |    2 +-
 lib/platform/gitea/README.md                  |    8 +
 lib/platform/gitea/gitea-got-wrapper.ts       |   68 +
 lib/platform/gitea/gitea-helper.ts            |  507 ++++++
 lib/platform/gitea/index.ts                   |  956 ++++++++++
 lib/util/got/auth.ts                          |    6 +-
 .../platform/__snapshots__/index.spec.ts.snap |   45 +
 .../gitea/__snapshots__/index.spec.ts.snap    |  169 ++
 test/platform/gitea/gitea-got-wrapper.spec.ts |   84 +
 test/platform/gitea/gitea-helper.spec.ts      |  836 +++++++++
 test/platform/gitea/index.spec.ts             | 1550 +++++++++++++++++
 test/platform/index.spec.ts                   |   12 +
 15 files changed, 4249 insertions(+), 2 deletions(-)
 create mode 100644 lib/platform/gitea/README.md
 create mode 100644 lib/platform/gitea/gitea-got-wrapper.ts
 create mode 100644 lib/platform/gitea/gitea-helper.ts
 create mode 100644 lib/platform/gitea/index.ts
 create mode 100644 test/platform/gitea/__snapshots__/index.spec.ts.snap
 create mode 100644 test/platform/gitea/gitea-got-wrapper.spec.ts
 create mode 100644 test/platform/gitea/gitea-helper.spec.ts
 create mode 100644 test/platform/gitea/index.spec.ts

diff --git a/docs/development/self-hosting.md b/docs/development/self-hosting.md
index 7e7482d52b..69f3d32426 100644
--- a/docs/development/self-hosting.md
+++ b/docs/development/self-hosting.md
@@ -133,6 +133,12 @@ First, [create a personal access token](https://docs.microsoft.com/en-us/azure/d
 Configure it either as `token` in your `config.js` file, or in environment variable `RENOVATE_TOKEN`, or via CLI `--token=`.
 Don't forget to configure `platform=azure` somewhere in config.
 
+### Gitea
+
+First, [create a access token](https://docs.gitea.io/en-us/api-usage/#authentication-via-the-api) for your bot account.
+Configure it as `token` in your `config.js` file, or in environment variable `RENOVATE_TOKEN`, or via CLI `--token=`.
+Don't forget to configure `platform=gitea` somewhere in config.
+
 ## GitHub.com token for release notes
 
 If you are running on any platform except github.com, it's important to also configure `GITHUB_COM_TOKEN` containing a personal access token for github.com. This account can actually be _any_ account on GitHub, and needs only read-only access. It's used when fetching release notes for repositories in order to increase the hourly API limit.
diff --git a/docs/usage/modules/platform.md b/docs/usage/modules/platform.md
index 0619e4e671..8a6261c896 100644
--- a/docs/usage/modules/platform.md
+++ b/docs/usage/modules/platform.md
@@ -9,3 +9,4 @@ Currently supported platforms are:
 - Bitbucket Server
 - GitHub (github.com, GitHub Enterprise)
 - GitLab (gitlab.com, self-hosted)
+- Gitea (gitea.com, self-hosted)
diff --git a/lib/constants/platforms.ts b/lib/constants/platforms.ts
index f3ba498bc4..07b779c55e 100644
--- a/lib/constants/platforms.ts
+++ b/lib/constants/platforms.ts
@@ -1,5 +1,6 @@
 export const PLATFORM_TYPE_AZURE = 'azure';
 export const PLATFORM_TYPE_BITBUCKET = 'bitbucket';
 export const PLATFORM_TYPE_BITBUCKET_SERVER = 'bitbucket-server';
+export const PLATFORM_TYPE_GITEA = 'gitea';
 export const PLATFORM_TYPE_GITHUB = 'github';
 export const PLATFORM_TYPE_GITLAB = 'gitlab';
diff --git a/lib/platform/common.ts b/lib/platform/common.ts
index e85a0b7c7e..c15596d35e 100644
--- a/lib/platform/common.ts
+++ b/lib/platform/common.ts
@@ -178,7 +178,7 @@ export interface Platform {
   deleteBranch(branchName: string, closePr?: boolean): Promise<void>;
   ensureComment(ensureComment: EnsureCommentConfig): Promise<boolean>;
   branchExists(branchName: string): Promise<boolean>;
-  setBaseBranch(baseBranch: string): Promise<void>;
+  setBaseBranch(baseBranch?: string): Promise<void>;
   commitFilesToBranch(commitFile: CommitFilesConfig): Promise<void>;
   getPr(number: number): Promise<Pr>;
   findPr(findPRConfig: FindPRConfig): Promise<Pr>;
diff --git a/lib/platform/gitea/README.md b/lib/platform/gitea/README.md
new file mode 100644
index 0000000000..c24a112da4
--- /dev/null
+++ b/lib/platform/gitea/README.md
@@ -0,0 +1,8 @@
+# Gitea
+
+Gitea support is considered in **beta** release status. Mostly, it just needs more feedback/testing. If you have been using it and think it's reliable, please let us know.
+
+## Unsupported platform features/concepts
+
+- **Adding reviewers to PRs not supported**: While Gitea supports a basic implementation for supporting PR reviews, no API support has been implemented so far.
+- **Ignoring Renovate PRs by close**: As Gitea does not expose the branch name of a PR once it has been deleted, all issued pull requests are immortal.
diff --git a/lib/platform/gitea/gitea-got-wrapper.ts b/lib/platform/gitea/gitea-got-wrapper.ts
new file mode 100644
index 0000000000..84bac293aa
--- /dev/null
+++ b/lib/platform/gitea/gitea-got-wrapper.ts
@@ -0,0 +1,68 @@
+import URL from 'url';
+import { GotApi, GotApiOptions, GotResponse } from '../common';
+import { PLATFORM_TYPE_GITEA } from '../../constants/platforms';
+import got from '../../util/got';
+
+const hostType = PLATFORM_TYPE_GITEA;
+let baseUrl: string;
+
+function getPaginationContainer(body: any): any[] {
+  if (Array.isArray(body) && body.length) {
+    return body;
+  }
+  if (Array.isArray(body?.data) && body.data.length) {
+    return body.data;
+  }
+
+  return null;
+}
+
+async function get(path: string, options?: any): Promise<GotResponse> {
+  const opts = {
+    hostType,
+    baseUrl,
+    json: true,
+    ...options,
+  };
+
+  const res = await got(path, opts);
+  const pc = getPaginationContainer(res.body);
+  if (opts.paginate && pc) {
+    const url = URL.parse(res.url, true);
+    const total = parseInt(res.headers['x-total-count'] as string, 10);
+    let nextPage = parseInt(url.query.page as string, 10) || 1 + 1;
+
+    while (total && pc.length < total) {
+      nextPage += 1;
+      url.query.page = nextPage.toString();
+
+      const nextRes = await got(URL.format(url), opts);
+      pc.push(...getPaginationContainer(nextRes.body));
+    }
+  }
+
+  return res;
+}
+
+const helpers = ['get', 'post', 'put', 'patch', 'head', 'delete'];
+
+export type GiteaGotOptions = {
+  paginate?: boolean;
+  token?: string;
+} & GotApiOptions;
+
+export interface GiteaGotApi extends GotApi<GiteaGotOptions> {
+  setBaseUrl(url: string): void;
+}
+
+export const api: GiteaGotApi = {} as any;
+
+for (const x of helpers) {
+  (api as any)[x] = (path: string, options: any): Promise<GotResponse> =>
+    get(path, { ...options, method: x.toUpperCase() });
+}
+
+// eslint-disable-next-line @typescript-eslint/unbound-method
+api.setBaseUrl = (e: string): void => {
+  baseUrl = e;
+};
diff --git a/lib/platform/gitea/gitea-helper.ts b/lib/platform/gitea/gitea-helper.ts
new file mode 100644
index 0000000000..c96b559a93
--- /dev/null
+++ b/lib/platform/gitea/gitea-helper.ts
@@ -0,0 +1,507 @@
+import { URLSearchParams } from 'url';
+import { api, GiteaGotOptions } from './gitea-got-wrapper';
+import { GotResponse } from '../common';
+
+export type PRState = 'open' | 'closed' | 'all';
+export type IssueState = 'open' | 'closed' | 'all';
+export type CommitStatusType =
+  | 'pending'
+  | 'success'
+  | 'error'
+  | 'failure'
+  | 'warning'
+  | 'unknown';
+export type PRMergeMethod = 'merge' | 'rebase' | 'rebase-merge' | 'squash';
+
+export interface PR {
+  number: number;
+  state: PRState;
+  title: string;
+  body: string;
+  mergeable: boolean;
+  created_at: string;
+  closed_at: string;
+  diff_url: string;
+  base?: {
+    ref: string;
+  };
+  head?: {
+    ref: string;
+    sha: string;
+    repo?: Repo;
+  };
+}
+
+export interface Issue {
+  number: number;
+  state: IssueState;
+  title: string;
+  body: string;
+  assignees: User[];
+}
+
+export interface User {
+  id: number;
+  email: string;
+  full_name: string;
+  username: string;
+}
+
+export interface Repo {
+  allow_merge_commits: boolean;
+  allow_rebase: boolean;
+  allow_rebase_explicit: boolean;
+  allow_squash_merge: boolean;
+  archived: boolean;
+  clone_url: string;
+  default_branch: string;
+  empty: boolean;
+  fork: boolean;
+  full_name: string;
+  mirror: boolean;
+  owner: User;
+  permissions: RepoPermission;
+}
+
+export interface RepoPermission {
+  admin: boolean;
+  pull: boolean;
+  push: boolean;
+}
+
+export interface RepoSearchResults {
+  ok: boolean;
+  data: Repo[];
+}
+
+export interface RepoContents {
+  path: string;
+  content?: string;
+  contentString?: string;
+}
+
+export interface Comment {
+  id: number;
+  body: string;
+}
+
+export interface Label {
+  id: number;
+  name: string;
+  description: string;
+  color: string;
+}
+
+export interface Branch {
+  name: string;
+  commit: Commit;
+}
+
+export interface Commit {
+  id: string;
+  author: CommitUser;
+}
+
+export interface CommitUser {
+  name: string;
+  email: string;
+  username: string;
+}
+
+export interface CommitStatus {
+  id: number;
+  status: CommitStatusType;
+  context: string;
+  description: string;
+  target_url: string;
+}
+
+export interface CombinedCommitStatus {
+  worstStatus: CommitStatusType;
+  statuses: CommitStatus[];
+}
+
+export type RepoSearchParams = {
+  uid?: number;
+};
+
+export type IssueCreateParams = {} & IssueUpdateParams;
+
+export type IssueUpdateParams = {
+  title?: string;
+  body?: string;
+  state?: IssueState;
+  assignees?: string[];
+};
+
+export type IssueSearchParams = {
+  state?: IssueState;
+};
+
+export type PRCreateParams = {
+  base?: string;
+  head?: string;
+} & PRUpdateParams;
+
+export type PRUpdateParams = {
+  title?: string;
+  body?: string;
+  assignees?: string[];
+  labels?: number[];
+  state?: PRState;
+};
+
+export type PRSearchParams = {
+  state?: PRState;
+  labels?: number[];
+};
+
+export type PRMergeParams = {
+  Do: PRMergeMethod;
+};
+
+export type CommentCreateParams = {} & CommentUpdateParams;
+
+export type CommentUpdateParams = {
+  body: string;
+};
+
+export type CommitStatusCreateParams = {
+  context?: string;
+  description?: string;
+  state?: CommitStatusType;
+  target_url?: string;
+};
+
+const urlEscape = (raw: string): string => encodeURIComponent(raw);
+const commitStatusStates: CommitStatusType[] = [
+  'unknown',
+  'success',
+  'pending',
+  'warning',
+  'failure',
+  'error',
+];
+
+function queryParams(params: Record<string, any>): URLSearchParams {
+  const usp = new URLSearchParams();
+  for (const [k, v] of Object.entries(params)) {
+    if (Array.isArray(v)) {
+      for (const item of v) {
+        usp.append(k, item.toString());
+      }
+    } else {
+      usp.append(k, v.toString());
+    }
+  }
+  return usp;
+}
+
+export async function getCurrentUser(options?: GiteaGotOptions): Promise<User> {
+  const url = 'user';
+  const res: GotResponse<User> = await api.get(url, options);
+
+  return res.body;
+}
+
+export async function searchRepos(
+  params: RepoSearchParams,
+  options?: GiteaGotOptions
+): Promise<Repo[]> {
+  const query = queryParams(params).toString();
+  const url = `repos/search?${query}`;
+  const res: GotResponse<RepoSearchResults> = await api.get(url, {
+    ...options,
+    paginate: true,
+  });
+
+  if (!res.body.ok) {
+    throw new Error(
+      'Unable to search for repositories, ok flag has not been set'
+    );
+  }
+
+  return res.body.data;
+}
+
+export async function getRepo(
+  repoPath: string,
+  options?: GiteaGotOptions
+): Promise<Repo> {
+  const url = `repos/${repoPath}`;
+  const res: GotResponse<Repo> = await api.get(url, options);
+
+  return res.body;
+}
+
+export async function getRepoContents(
+  repoPath: string,
+  filePath: string,
+  ref?: string,
+  options?: GiteaGotOptions
+): Promise<RepoContents> {
+  const query = queryParams(ref ? { ref } : {}).toString();
+  const url = `repos/${repoPath}/contents/${urlEscape(filePath)}?${query}`;
+  const res: GotResponse<RepoContents> = await api.get(url, options);
+
+  if (res.body.content) {
+    res.body.contentString = Buffer.from(res.body.content, 'base64').toString();
+  }
+
+  return res.body;
+}
+
+export async function createPR(
+  repoPath: string,
+  params: PRCreateParams,
+  options?: GiteaGotOptions
+): Promise<PR> {
+  const url = `repos/${repoPath}/pulls`;
+  const res: GotResponse<PR> = await api.post(url, {
+    ...options,
+    body: params,
+  });
+
+  return res.body;
+}
+
+export async function updatePR(
+  repoPath: string,
+  idx: number,
+  params: PRUpdateParams,
+  options?: GiteaGotOptions
+): Promise<PR> {
+  const url = `repos/${repoPath}/pulls/${idx}`;
+  const res: GotResponse<PR> = await api.patch(url, {
+    ...options,
+    body: params,
+  });
+
+  return res.body;
+}
+
+export async function closePR(
+  repoPath: string,
+  idx: number,
+  options?: GiteaGotOptions
+): Promise<void> {
+  await updatePR(repoPath, idx, {
+    ...options,
+    state: 'closed',
+  });
+}
+
+export async function mergePR(
+  repoPath: string,
+  idx: number,
+  method: PRMergeMethod,
+  options?: GiteaGotOptions
+): Promise<void> {
+  const params: PRMergeParams = { Do: method };
+  const url = `repos/${repoPath}/pulls/${idx}/merge`;
+  await api.post(url, {
+    ...options,
+    body: params,
+  });
+}
+
+export async function getPR(
+  repoPath: string,
+  idx: number,
+  options?: GiteaGotOptions
+): Promise<PR> {
+  const url = `repos/${repoPath}/pulls/${idx}`;
+  const res: GotResponse<PR> = await api.get(url, options);
+
+  return res.body;
+}
+
+export async function searchPRs(
+  repoPath: string,
+  params: PRSearchParams,
+  options?: GiteaGotOptions
+): Promise<PR[]> {
+  const query = queryParams(params).toString();
+  const url = `repos/${repoPath}/pulls?${query}`;
+  const res: GotResponse<PR[]> = await api.get(url, {
+    ...options,
+    paginate: true,
+  });
+
+  return res.body;
+}
+
+export async function createIssue(
+  repoPath: string,
+  params: IssueCreateParams,
+  options?: GiteaGotOptions
+): Promise<Issue> {
+  const url = `repos/${repoPath}/issues`;
+  const res: GotResponse<Issue> = await api.post(url, {
+    ...options,
+    body: params,
+  });
+
+  return res.body;
+}
+
+export async function updateIssue(
+  repoPath: string,
+  idx: number,
+  params: IssueUpdateParams,
+  options?: GiteaGotOptions
+): Promise<Issue> {
+  const url = `repos/${repoPath}/issues/${idx}`;
+  const res: GotResponse<Issue> = await api.patch(url, {
+    ...options,
+    body: params,
+  });
+
+  return res.body;
+}
+
+export async function closeIssue(
+  repoPath: string,
+  idx: number,
+  options?: GiteaGotOptions
+): Promise<void> {
+  await updateIssue(repoPath, idx, {
+    ...options,
+    state: 'closed',
+  });
+}
+
+export async function searchIssues(
+  repoPath: string,
+  params: IssueSearchParams,
+  options?: GiteaGotOptions
+): Promise<Issue[]> {
+  const query = queryParams(params).toString();
+  const url = `repos/${repoPath}/issues?${query}`;
+  const res: GotResponse<Issue[]> = await api.get(url, {
+    ...options,
+    paginate: true,
+  });
+
+  return res.body;
+}
+
+export async function getRepoLabels(
+  repoPath: string,
+  options?: GiteaGotOptions
+): Promise<Label[]> {
+  const url = `repos/${repoPath}/labels`;
+  const res: GotResponse<Label[]> = await api.get(url, options);
+
+  return res.body;
+}
+
+export async function unassignLabel(
+  repoPath: string,
+  issue: number,
+  label: number,
+  options?: GiteaGotOptions
+): Promise<void> {
+  const url = `repos/${repoPath}/issues/${issue}/labels/${label}`;
+  await api.delete(url, options);
+}
+
+export async function createComment(
+  repoPath: string,
+  issue: number,
+  body: string,
+  options?: GiteaGotOptions
+): Promise<Comment> {
+  const params: CommentCreateParams = { body };
+  const url = `repos/${repoPath}/issues/${issue}/comments`;
+  const res: GotResponse<Comment> = await api.post(url, {
+    ...options,
+    body: params,
+  });
+
+  return res.body;
+}
+
+export async function updateComment(
+  repoPath: string,
+  idx: number,
+  body: string,
+  options?: GiteaGotOptions
+): Promise<Comment> {
+  const params: CommentUpdateParams = { body };
+  const url = `repos/${repoPath}/issues/comments/${idx}`;
+  const res: GotResponse<Comment> = await api.patch(url, {
+    ...options,
+    body: params,
+  });
+
+  return res.body;
+}
+
+export async function deleteComment(
+  repoPath,
+  idx: number,
+  options?: GiteaGotOptions
+): Promise<void> {
+  const url = `repos/${repoPath}/issues/comments/${idx}`;
+  await api.delete(url, options);
+}
+
+export async function getComments(
+  repoPath,
+  issue: number,
+  options?: GiteaGotOptions
+): Promise<Comment[]> {
+  const url = `repos/${repoPath}/issues/${issue}/comments`;
+  const res: GotResponse<Comment[]> = await api.get(url, options);
+
+  return res.body;
+}
+
+export async function createCommitStatus(
+  repoPath: string,
+  branchCommit: string,
+  params: CommitStatusCreateParams,
+  options?: GiteaGotOptions
+): Promise<CommitStatus> {
+  const url = `repos/${repoPath}/statuses/${branchCommit}`;
+  const res: GotResponse<CommitStatus> = await api.post(url, {
+    ...options,
+    body: params,
+  });
+
+  return res.body;
+}
+
+export async function getCombinedCommitStatus(
+  repoPath: string,
+  branchName: string,
+  options?: GiteaGotOptions
+): Promise<CombinedCommitStatus> {
+  const url = `repos/${repoPath}/commits/${urlEscape(branchName)}/statuses`;
+  const res: GotResponse<CommitStatus[]> = await api.get(url, {
+    ...options,
+    paginate: true,
+  });
+
+  let worstState = 0;
+  for (const cs of res.body) {
+    worstState = Math.max(worstState, commitStatusStates.indexOf(cs.status));
+  }
+
+  return {
+    worstStatus: commitStatusStates[worstState],
+    statuses: res.body,
+  };
+}
+
+export async function getBranch(
+  repoPath: string,
+  branchName: string,
+  options?: GiteaGotOptions
+): Promise<Branch> {
+  const url = `repos/${repoPath}/branches/${urlEscape(branchName)}`;
+  const res: GotResponse<Branch> = await api.get(url, options);
+
+  return res.body;
+}
diff --git a/lib/platform/gitea/index.ts b/lib/platform/gitea/index.ts
new file mode 100644
index 0000000000..e3308a8517
--- /dev/null
+++ b/lib/platform/gitea/index.ts
@@ -0,0 +1,956 @@
+import URL from 'url';
+import GitStorage, { CommitFilesConfig, StatusResult } from '../git/storage';
+import * as hostRules from '../../util/host-rules';
+import {
+  BranchStatus,
+  BranchStatusConfig,
+  CreatePRConfig,
+  EnsureCommentConfig,
+  EnsureIssueConfig,
+  FindPRConfig,
+  Issue,
+  Platform,
+  PlatformConfig,
+  Pr,
+  RepoConfig,
+  RepoParams,
+  VulnerabilityAlert,
+} from '../common';
+import { api } from './gitea-got-wrapper';
+import { PLATFORM_TYPE_GITEA } from '../../constants/platforms';
+import { logger } from '../../logger';
+import {
+  REPOSITORY_ACCESS_FORBIDDEN,
+  REPOSITORY_ARCHIVED,
+  REPOSITORY_BLOCKED,
+  REPOSITORY_CHANGED,
+  REPOSITORY_DISABLED,
+  REPOSITORY_EMPTY,
+  REPOSITORY_MIRRORED,
+} from '../../constants/error-messages';
+import { RenovateConfig } from '../../config';
+import { configFileNames } from '../../config/app-strings';
+import { smartTruncate } from '../utils/pr-body';
+import { sanitize } from '../../util/sanitize';
+import {
+  BRANCH_STATUS_FAILED,
+  BRANCH_STATUS_PENDING,
+  BRANCH_STATUS_SUCCESS,
+} from '../../constants/branch-constants';
+import * as helper from './gitea-helper';
+
+type GiteaRenovateConfig = {
+  endpoint: string;
+  token: string;
+} & RenovateConfig;
+
+interface GiteaRepoConfig {
+  storage: GitStorage;
+  repository: string;
+  localDir: string;
+  defaultBranch: string;
+  baseBranch: string;
+  mergeMethod: helper.PRMergeMethod;
+
+  prList: Promise<Pr[]> | null;
+  issueList: Promise<Issue[]> | null;
+  labelList: Promise<helper.Label[]> | null;
+}
+
+const defaults: any = {
+  hostType: PLATFORM_TYPE_GITEA,
+  endpoint: 'https://gitea.com/api/v1/',
+};
+const defaultConfigFile = configFileNames[0];
+
+let config: GiteaRepoConfig = {} as any;
+let botUserID: number;
+
+function toRenovateIssue(data: helper.Issue): Issue {
+  return {
+    number: data.number,
+    state: data.state,
+    title: data.title,
+    body: data.body,
+  };
+}
+
+function toRenovatePR(data: helper.PR): Pr | null {
+  if (!data) {
+    return null;
+  }
+
+  if (
+    !data.base?.ref ||
+    !data.head?.ref ||
+    !data.head?.sha ||
+    !data.head?.repo?.full_name
+  ) {
+    logger.trace(
+      `Skipping Pull Request #${data.number} due to missing base and/or head branch`
+    );
+    return null;
+  }
+
+  return {
+    number: data.number,
+    displayNumber: `Pull Request #${data.number}`,
+    state: data.state,
+    title: data.title,
+    body: data.body,
+    sha: data.head.sha,
+    branchName: data.head.ref,
+    targetBranch: data.base.ref,
+    sourceRepo: data.head.repo.full_name,
+    createdAt: data.created_at,
+    closedAt: data.closed_at,
+    canMerge: data.mergeable,
+    isConflicted: !data.mergeable,
+    isStale: undefined,
+    isModified: undefined,
+  };
+}
+
+function matchesState(actual: string, expected: string): boolean {
+  if (expected === 'all') {
+    return true;
+  }
+  if (expected.startsWith('!')) {
+    return actual !== expected.substring(1);
+  }
+
+  return actual === expected;
+}
+
+function findCommentByTopic(
+  comments: helper.Comment[],
+  topic: string
+): helper.Comment | null {
+  return comments.find(c => c.body.startsWith(`### ${topic}\n\n`));
+}
+
+async function isPRModified(
+  repoPath: string,
+  branchName: string
+): Promise<boolean> {
+  try {
+    const branch = await helper.getBranch(repoPath, branchName);
+    const branchCommitEmail = branch.commit.author.email;
+    const configEmail = global.gitAuthor.email;
+
+    if (branchCommitEmail === configEmail) {
+      return false;
+    }
+
+    logger.debug(
+      { branchCommitEmail, configEmail },
+      'Last committer to branch does not match bot, PR cannot be rebased'
+    );
+    return true;
+  } catch (err) {
+    logger.warn({ err }, 'Error getting PR branch, marking as modified');
+    return true;
+  }
+}
+
+async function retrieveDefaultConfig(
+  repoPath: string,
+  branchName: string
+): Promise<RenovateConfig> {
+  const contents = await helper.getRepoContents(
+    repoPath,
+    defaultConfigFile,
+    branchName
+  );
+
+  return JSON.parse(contents.contentString);
+}
+
+function getLabelList(): Promise<helper.Label[]> {
+  if (config.labelList === null) {
+    config.labelList = helper
+      .getRepoLabels(config.repository, {
+        useCache: false,
+      })
+      .then(labels => {
+        logger.debug(`Retrieved ${labels.length} Labels`);
+        return labels;
+      });
+  }
+
+  return config.labelList;
+}
+
+async function lookupLabelByName(name: string): Promise<number | null> {
+  logger.debug(`lookupLabelByName(${name})`);
+  const labelList = await getLabelList();
+  return labelList.find(l => l.name === name)?.id;
+}
+
+const platform: Platform = {
+  async initPlatform({
+    endpoint,
+    token,
+  }: GiteaRenovateConfig): Promise<PlatformConfig> {
+    if (!token) {
+      throw new Error('Init: You must configure a Gitea personal access token');
+    }
+
+    if (endpoint) {
+      // Ensure endpoint contains trailing slash
+      defaults.endpoint = endpoint.replace(/\/?$/, '/');
+    } else {
+      logger.info('Using default Gitea endpoint: ' + defaults.endpoint);
+    }
+    api.setBaseUrl(defaults.endpoint);
+
+    let gitAuthor: string;
+    try {
+      const user = await helper.getCurrentUser({ token });
+      gitAuthor = `${user.full_name || user.username} <${user.email}>`;
+      botUserID = user.id;
+    } catch (err) {
+      logger.info({ err }, 'Error authenticating with Gitea. Check your token');
+      throw new Error('Init: Authentication failure');
+    }
+
+    return {
+      endpoint: defaults.endpoint,
+      gitAuthor,
+    };
+  },
+
+  async initRepo({
+    repository,
+    localDir,
+    optimizeForDisabled,
+  }: RepoParams): Promise<RepoConfig> {
+    let renovateConfig: RenovateConfig;
+    let repo: helper.Repo;
+
+    config = {} as any;
+    config.repository = repository;
+    config.localDir = localDir;
+
+    // Attempt to fetch information about repository
+    try {
+      repo = await helper.getRepo(repository);
+    } catch (err) {
+      logger.info({ err }, 'Unknown Gitea initRepo error');
+      throw err;
+    }
+
+    // Ensure appropriate repository state and permissions
+    if (repo.archived) {
+      logger.info(
+        'Repository is archived - throwing error to abort renovation'
+      );
+      throw new Error(REPOSITORY_ARCHIVED);
+    }
+    if (repo.mirror) {
+      logger.info(
+        'Repository is a mirror - throwing error to abort renovation'
+      );
+      throw new Error(REPOSITORY_MIRRORED);
+    }
+    if (!repo.permissions.pull || !repo.permissions.push) {
+      logger.info(
+        'Repository does not permit pull and push - throwing error to abort renovation'
+      );
+      throw new Error(REPOSITORY_ACCESS_FORBIDDEN);
+    }
+    if (repo.empty) {
+      logger.info('Repository is empty - throwing error to abort renovation');
+      throw new Error(REPOSITORY_EMPTY);
+    }
+
+    if (repo.allow_rebase) {
+      config.mergeMethod = 'rebase';
+    } else if (repo.allow_rebase_explicit) {
+      config.mergeMethod = 'rebase-merge';
+    } else if (repo.allow_squash_merge) {
+      config.mergeMethod = 'squash';
+    } else if (repo.allow_merge_commits) {
+      config.mergeMethod = 'merge';
+    } else {
+      logger.info(
+        'Repository has no allowed merge methods - throwing error to abort renovation'
+      );
+      throw new Error(REPOSITORY_BLOCKED);
+    }
+
+    // Determine author email and branches
+    config.defaultBranch = repo.default_branch;
+    config.baseBranch = config.defaultBranch;
+    logger.debug(`${repository} default branch = ${config.baseBranch}`);
+
+    // Optionally check if Renovate is disabled by attempting to fetch default configuration file
+    if (optimizeForDisabled) {
+      try {
+        if (!renovateConfig) {
+          renovateConfig = await retrieveDefaultConfig(
+            config.repository,
+            config.defaultBranch
+          );
+        }
+      } catch (err) {
+        // Do nothing
+      }
+
+      if (renovateConfig && renovateConfig.enabled === false) {
+        throw new Error(REPOSITORY_DISABLED);
+      }
+    }
+
+    // Find options for current host and determine Git endpoint
+    const opts = hostRules.find({
+      hostType: PLATFORM_TYPE_GITEA,
+      url: defaults.endpoint,
+    });
+    const gitEndpoint = URL.parse(repo.clone_url);
+    gitEndpoint.auth = opts.token;
+
+    // Initialize Git storage
+    config.storage = new GitStorage();
+    await config.storage.initRepo({
+      ...config,
+      url: URL.format(gitEndpoint),
+    });
+
+    // Reset cached resources
+    config.prList = null;
+    config.issueList = null;
+    config.labelList = null;
+
+    return {
+      baseBranch: config.baseBranch,
+      isFork: !!repo.fork,
+    };
+  },
+
+  async getRepos(): Promise<string[]> {
+    logger.info('Auto-discovering Gitea repositories');
+    try {
+      const repos = await helper.searchRepos({ uid: botUserID });
+      return repos.map(r => r.full_name);
+    } catch (err) {
+      logger.error({ err }, 'Gitea getRepos() error');
+      throw err;
+    }
+  },
+
+  cleanRepo(): Promise<void> {
+    if (config.storage) {
+      config.storage.cleanRepo();
+    }
+    config = {} as any;
+    return Promise.resolve();
+  },
+
+  async setBranchStatus({
+    branchName,
+    context,
+    description,
+    state,
+    url: target_url,
+  }: BranchStatusConfig): Promise<void> {
+    try {
+      // Create new status for branch commit
+      const branchCommit = await config.storage.getBranchCommit(branchName);
+      await helper.createCommitStatus(config.repository, branchCommit, {
+        state: state ? (state as helper.CommitStatusType) : 'pending',
+        context,
+        description,
+        ...(target_url && { target_url }),
+      });
+
+      // Refresh caches by re-fetching commit status for branch
+      await helper.getCombinedCommitStatus(config.repository, branchName, {
+        useCache: false,
+      });
+    } catch (err) {
+      logger.warn({ err }, 'Failed to set branch status');
+    }
+  },
+
+  async getBranchStatus(
+    branchName: string,
+    requiredStatusChecks?: string[] | null
+  ): Promise<BranchStatus> {
+    if (!requiredStatusChecks) {
+      return BRANCH_STATUS_SUCCESS;
+    }
+
+    if (Array.isArray(requiredStatusChecks) && requiredStatusChecks.length) {
+      logger.warn({ requiredStatusChecks }, 'Unsupported requiredStatusChecks');
+      return BRANCH_STATUS_FAILED;
+    }
+
+    let ccs: helper.CombinedCommitStatus;
+    try {
+      ccs = await helper.getCombinedCommitStatus(config.repository, branchName);
+    } catch (err) {
+      if (err.statusCode === 404) {
+        logger.info(
+          'Received 404 when checking branch status, assuming branch deletion'
+        );
+        throw new Error(REPOSITORY_CHANGED);
+      }
+
+      logger.info('Unknown error when checking branch status');
+      throw err;
+    }
+
+    logger.debug({ ccs }, 'Branch status check result');
+    switch (ccs.worstStatus) {
+      case 'unknown':
+      case 'pending':
+        return BRANCH_STATUS_PENDING;
+      case 'success':
+        return BRANCH_STATUS_SUCCESS;
+      default:
+        return BRANCH_STATUS_FAILED;
+    }
+  },
+
+  async getBranchStatusCheck(
+    branchName: string,
+    context: string
+  ): Promise<string> {
+    const ccs = await helper.getCombinedCommitStatus(
+      config.repository,
+      branchName
+    );
+    const cs = ccs.statuses.find(s => s.context === context);
+
+    return cs ? cs.status : null;
+  },
+
+  async setBaseBranch(
+    baseBranch: string = config.defaultBranch
+  ): Promise<void> {
+    config.baseBranch = baseBranch;
+    await config.storage.setBaseBranch(baseBranch);
+  },
+
+  getPrList(): Promise<Pr[]> {
+    if (config.prList === null) {
+      config.prList = helper
+        .searchPRs(config.repository, {}, { useCache: false })
+        .then(prs => {
+          const prList = prs.map(toRenovatePR).filter(Boolean);
+          logger.debug(`Retrieved ${prList.length} Pull Requests`);
+          return prList;
+        });
+    }
+
+    return config.prList;
+  },
+
+  async getPr(number: number): Promise<Pr | null> {
+    // Search for pull request in cached list or attempt to query directly
+    const prList = await platform.getPrList();
+    let pr = prList.find(p => p.number === number);
+    if (pr) {
+      logger.debug('Returning from cached PRs');
+    } else {
+      logger.debug('PR not found in cached PRs - trying to fetch directly');
+      const gpr = await helper.getPR(config.repository, number);
+      pr = toRenovatePR(gpr);
+
+      // Add pull request to cache for further lookups / queries
+      if (config.prList !== null) {
+        (await config.prList).push(pr);
+      }
+    }
+
+    // Abort and return null if no match was found
+    if (!pr) {
+      return null;
+    }
+
+    // Enrich pull request with additional information which is more expensive to fetch
+    if (pr.isStale === undefined) {
+      pr.isStale = await platform.isBranchStale(pr.branchName);
+    }
+    if (pr.isModified === undefined) {
+      pr.isModified = await isPRModified(config.repository, pr.branchName);
+    }
+
+    return pr;
+  },
+
+  async findPr({
+    branchName,
+    prTitle: title,
+    state = 'all',
+  }: FindPRConfig): Promise<Pr> {
+    logger.debug(`findPr(${branchName}, ${title}, ${state})`);
+    const prList = await platform.getPrList();
+    const pr = prList.find(
+      p =>
+        p.sourceRepo === config.repository &&
+        p.branchName === branchName &&
+        matchesState(p.state, state) &&
+        (!title || p.title === title)
+    );
+
+    if (pr) {
+      logger.debug(`Found PR #${pr.number}`);
+    }
+    return pr ?? null;
+  },
+
+  async createPr({
+    branchName,
+    prTitle: title,
+    prBody: rawBody,
+    labels: labelNames,
+    useDefaultBranch,
+  }: CreatePRConfig): Promise<Pr> {
+    const base = useDefaultBranch ? config.defaultBranch : config.baseBranch;
+    const head = branchName;
+    const body = sanitize(rawBody);
+
+    logger.debug(`Creating pull request: ${title} (${head} => ${base})`);
+    try {
+      const labels = Array.isArray(labelNames)
+        ? await Promise.all(labelNames.map(lookupLabelByName))
+        : [];
+      const gpr = await helper.createPR(config.repository, {
+        base,
+        head,
+        title,
+        body,
+        labels: labels.filter(Boolean),
+      });
+
+      const pr = toRenovatePR(gpr);
+      if (!pr) {
+        throw new Error('Can not parse newly created Pull Request');
+      }
+      if (config.prList !== null) {
+        (await config.prList).push(pr);
+      }
+
+      return pr;
+    } catch (err) {
+      // When the user manually deletes a branch from Renovate, the PR remains but is no longer linked to any branch. In
+      // the most recent versions of Gitea, the PR gets automatically closed when that happens, but older versions do
+      // not handle this properly and keep the PR open. As pushing a branch with the same name resurrects the PR, this
+      // would cause a HTTP 409 conflict error, which we hereby gracefully handle.
+      if (err.statusCode === 409) {
+        logger.warn(
+          `Attempting to gracefully recover from 409 Conflict response in createPr(${title}, ${branchName})`
+        );
+
+        // Refresh cached PR list and search for pull request with matching information
+        config.prList = null;
+        const pr = await platform.findPr({
+          branchName,
+          state: 'open',
+        });
+
+        // If a valid PR was found, return and gracefully recover from the error. Otherwise, abort and throw error.
+        if (pr) {
+          if (pr.title !== title || pr.body !== body) {
+            logger.info(
+              `Recovered from 409 Conflict, but PR for ${branchName} is outdated. Updating...`
+            );
+            await platform.updatePr(pr.number, title, body);
+            pr.title = title;
+            pr.body = body;
+          } else {
+            logger.info(
+              `Recovered from 409 Conflict and PR for ${branchName} is up-to-date`
+            );
+          }
+
+          return pr;
+        }
+      }
+
+      throw err;
+    }
+  },
+
+  async updatePr(number: number, title: string, body?: string): Promise<void> {
+    await helper.updatePR(config.repository, number, {
+      title,
+      ...(body && { body }),
+    });
+  },
+
+  async mergePr(number: number, branchName: string): Promise<boolean> {
+    try {
+      await helper.mergePR(config.repository, number, config.mergeMethod);
+      return true;
+    } catch (err) {
+      logger.warn({ err, number }, 'Merging of PR failed');
+      return false;
+    }
+  },
+
+  async getPrFiles(prNo: number): Promise<string[]> {
+    if (!prNo) {
+      return [];
+    }
+
+    // Retrieving a diff for a PR is not officially supported by Gitea as of today
+    // See tracking issue: https://github.com/go-gitea/gitea/issues/5561
+    // Workaround: Parse new paths in .diff file using regular expressions
+    const regex = /^diff --git a\/.+ b\/(.+)$/gm;
+    const pr = await helper.getPR(config.repository, prNo);
+    const diff = (await api.get(pr.diff_url)).body as string;
+
+    const changedFiles: string[] = [];
+    let match: string[];
+    do {
+      match = regex.exec(diff);
+      if (match) {
+        changedFiles.push(match[1]);
+      }
+    } while (match);
+
+    return changedFiles;
+  },
+
+  getIssueList(): Promise<Issue[]> {
+    if (config.issueList === null) {
+      config.issueList = helper
+        .searchIssues(config.repository, {}, { useCache: false })
+        .then(issues => {
+          const issueList = issues.map(toRenovateIssue);
+          logger.debug(`Retrieved ${issueList.length} Issues`);
+          return issueList;
+        });
+    }
+
+    return config.issueList;
+  },
+
+  async findIssue(title: string): Promise<Issue> {
+    const issueList = await platform.getIssueList();
+    const issue = issueList.find(i => i.state === 'open' && i.title === title);
+
+    if (issue) {
+      logger.debug(`Found Issue #${issue.number}`);
+    }
+    return issue ?? null;
+  },
+
+  async ensureIssue({
+    title,
+    body,
+    shouldReOpen,
+    once,
+  }: EnsureIssueConfig): Promise<'updated' | 'created' | null> {
+    logger.debug(`ensureIssue(${title})`);
+    try {
+      const issueList = await platform.getIssueList();
+      const issues = issueList.filter(i => i.title === title);
+
+      // Update any matching issues which currently exist
+      if (issues.length) {
+        let activeIssue = issues.find(i => i.state === 'open');
+
+        // If no active issue was found, decide if it shall be skipped, re-opened or updated without state change
+        if (!activeIssue) {
+          if (once) {
+            logger.debug('Issue already closed - skipping update');
+            return null;
+          }
+          if (shouldReOpen) {
+            logger.info('Reopening previously closed Issue');
+          }
+
+          // Pick the last issue in the list as the active one
+          activeIssue = issues[issues.length - 1];
+        }
+
+        // Close any duplicate issues
+        for (const issue of issues) {
+          if (issue.state === 'open' && issue.number !== activeIssue.number) {
+            logger.warn(`Closing duplicate Issue #${issue.number}`);
+            await helper.closeIssue(config.repository, issue.number);
+          }
+        }
+
+        // Check if issue has already correct state
+        if (activeIssue.body === body && activeIssue.state === 'open') {
+          logger.info(
+            `Issue #${activeIssue.number} is open and up to date - nothing to do`
+          );
+          return null;
+        }
+
+        // Update issue body and re-open if enabled
+        logger.info(`Updating Issue #${activeIssue.number}`);
+        await helper.updateIssue(config.repository, activeIssue.number, {
+          body,
+          state: shouldReOpen
+            ? 'open'
+            : (activeIssue.state as helper.IssueState),
+        });
+
+        return 'updated';
+      }
+
+      // Create new issue and reset cache
+      const issue = await helper.createIssue(config.repository, {
+        body,
+        title,
+      });
+      logger.info(`Created new Issue #${issue.number}`);
+      config.issueList = null;
+
+      return 'created';
+    } catch (err) {
+      logger.warn({ err }, 'Could not ensure issue');
+    }
+
+    return null;
+  },
+
+  async ensureIssueClosing(title: string): Promise<void> {
+    logger.debug(`ensureIssueClosing(${title})`);
+    const issueList = await platform.getIssueList();
+    for (const issue of issueList) {
+      if (issue.state === 'open' && issue.title === title) {
+        logger.info({ number: issue.number }, 'Closing issue');
+        await helper.closeIssue(config.repository, issue.number);
+      }
+    }
+  },
+
+  async deleteLabel(issue: number, labelName: string): Promise<void> {
+    logger.debug(`Deleting label ${labelName} from Issue #${issue}`);
+    const label = await lookupLabelByName(labelName);
+    if (label) {
+      await helper.unassignLabel(config.repository, issue, label);
+    } else {
+      logger.warn({ issue, labelName }, 'Failed to lookup label for deletion');
+    }
+
+    return null;
+  },
+
+  getRepoForceRebase(): Promise<boolean> {
+    return Promise.resolve(false);
+  },
+
+  async ensureComment({
+    number: issue,
+    topic,
+    content,
+  }: EnsureCommentConfig): Promise<boolean> {
+    if (topic === 'Renovate Ignore Notification') {
+      logger.info(
+        `Skipping ensureComment(${topic}) as ignoring PRs is unsupported on Gitea.`
+      );
+      return false;
+    }
+
+    try {
+      let body = sanitize(content);
+      const commentList = await helper.getComments(config.repository, issue);
+
+      // Search comment by either topic or exact body
+      let comment: helper.Comment = null;
+      if (topic) {
+        comment = findCommentByTopic(commentList, topic);
+        body = `### ${topic}\n\n${body}`;
+      } else {
+        comment = commentList.find(c => c.body === body);
+      }
+
+      // Create a new comment if no match has been found, otherwise update if necessary
+      if (!comment) {
+        const c = await helper.createComment(config.repository, issue, body);
+        logger.info(
+          { repository: config.repository, issue, comment: c.id },
+          'Comment added'
+        );
+      } else if (comment.body !== body) {
+        const c = await helper.updateComment(config.repository, issue, body);
+        logger.info(
+          { repository: config.repository, issue, comment: c.id },
+          'Comment updated'
+        );
+      } else {
+        logger.debug(`Comment #${comment.id} is already up-to-date`);
+      }
+
+      return true;
+    } catch (err) {
+      logger.warn({ err }, 'Error ensuring comment');
+      return false;
+    }
+  },
+
+  async ensureCommentRemoval(issue: number, topic: string): Promise<void> {
+    const commentList = await helper.getComments(config.repository, issue);
+    const comment = findCommentByTopic(commentList, topic);
+
+    // Abort and do nothing if no matching comment was found
+    if (!comment) {
+      return null;
+    }
+
+    // Attempt to delete comment
+    try {
+      await helper.deleteComment(config.repository, comment.id);
+    } catch (err) {
+      logger.warn({ err, issue, subject: topic }, 'Error deleting comment');
+    }
+
+    return null;
+  },
+
+  async getBranchPr(branchName: string): Promise<Pr | null> {
+    logger.debug(`getBranchPr(${branchName})`);
+    const pr = await platform.findPr({ branchName, state: 'open' });
+    return pr ? platform.getPr(pr.number) : null;
+  },
+
+  async deleteBranch(branchName: string, closePr?: boolean): Promise<void> {
+    logger.debug(`deleteBranch(${branchName})`);
+    if (closePr) {
+      const pr = await platform.getBranchPr(branchName);
+      if (pr) {
+        await helper.closePR(config.repository, pr.number);
+      }
+    }
+
+    return config.storage.deleteBranch(branchName);
+  },
+
+  async addAssignees(number: number, assignees: string[]): Promise<void> {
+    logger.debug(`Updating assignees ${assignees} on Issue #${number}`);
+    await helper.updateIssue(config.repository, number, {
+      assignees,
+    });
+  },
+
+  addReviewers(number: number, reviewers: string[]): Promise<void> {
+    // Adding reviewers to a PR through API is not supported by Gitea as of today
+    // See tracking issue: https://github.com/go-gitea/gitea/issues/5733
+    logger.debug(`Updating reviewers ${reviewers} on Pull Request #${number}`);
+    logger.warn('Unimplemented in Gitea: Reviewers');
+    return Promise.resolve();
+  },
+
+  commitFilesToBranch({
+    branchName,
+    files,
+    message,
+    parentBranch = config.baseBranch,
+  }: CommitFilesConfig): Promise<void> {
+    return config.storage.commitFilesToBranch({
+      branchName,
+      files,
+      message,
+      parentBranch,
+    });
+  },
+
+  getPrBody(prBody: string): string {
+    // Gitea does not preserve the branch name once the head branch gets deleted, so ignoring a PR by simply closing it
+    // results in an endless loop of Renovate creating the PR over and over again. This is not pretty, but can not be
+    // avoided without storing that information somewhere else, so at least warn the user about it.
+    return smartTruncate(
+      prBody.replace(
+        /:no_bell: \*\*Ignore\*\*: Close this PR and you won't be reminded about (this update|these updates) again./,
+        `:ghost: **Immortal**: This PR will be recreated if closed unmerged, as Gitea does not support ignoring PRs.`
+      ),
+      1000000
+    );
+  },
+
+  isBranchStale(branchName: string): Promise<boolean> {
+    return config.storage.isBranchStale(branchName);
+  },
+
+  setBranchPrefix(branchPrefix: string): Promise<void> {
+    return config.storage.setBranchPrefix(branchPrefix);
+  },
+
+  branchExists(branchName: string): Promise<boolean> {
+    return config.storage.branchExists(branchName);
+  },
+
+  mergeBranch(branchName: string): Promise<void> {
+    return config.storage.mergeBranch(branchName);
+  },
+
+  getBranchLastCommitTime(branchName: string): Promise<Date> {
+    return config.storage.getBranchLastCommitTime(branchName);
+  },
+
+  getFile(lockFileName: string, branchName?: string): Promise<string> {
+    return config.storage.getFile(lockFileName, branchName);
+  },
+
+  getRepoStatus(): Promise<StatusResult> {
+    return config.storage.getRepoStatus();
+  },
+
+  getFileList(): Promise<string[]> {
+    return config.storage.getFileList(config.baseBranch);
+  },
+
+  getAllRenovateBranches(branchPrefix: string): Promise<string[]> {
+    return config.storage.getAllRenovateBranches(branchPrefix);
+  },
+
+  getCommitMessages(): Promise<string[]> {
+    return config.storage.getCommitMessages();
+  },
+
+  getVulnerabilityAlerts(): Promise<VulnerabilityAlert[]> {
+    return Promise.resolve([]);
+  },
+};
+
+export const {
+  addAssignees,
+  addReviewers,
+  branchExists,
+  cleanRepo,
+  commitFilesToBranch,
+  createPr,
+  deleteBranch,
+  deleteLabel,
+  ensureComment,
+  ensureCommentRemoval,
+  ensureIssue,
+  ensureIssueClosing,
+  findIssue,
+  findPr,
+  getAllRenovateBranches,
+  getBranchLastCommitTime,
+  getBranchPr,
+  getBranchStatus,
+  getBranchStatusCheck,
+  getCommitMessages,
+  getFile,
+  getFileList,
+  getIssueList,
+  getPr,
+  getPrBody,
+  getPrFiles,
+  getPrList,
+  getRepoForceRebase,
+  getRepoStatus,
+  getRepos,
+  getVulnerabilityAlerts,
+  initPlatform,
+  initRepo,
+  isBranchStale,
+  mergeBranch,
+  mergePr,
+  setBaseBranch,
+  setBranchPrefix,
+  setBranchStatus,
+  updatePr,
+} = platform;
diff --git a/lib/util/got/auth.ts b/lib/util/got/auth.ts
index dde19ac7c8..df329ea12e 100644
--- a/lib/util/got/auth.ts
+++ b/lib/util/got/auth.ts
@@ -1,6 +1,7 @@
 import { logger } from '../../logger';
 import { create } from './util';
 import {
+  PLATFORM_TYPE_GITEA,
   PLATFORM_TYPE_GITHUB,
   PLATFORM_TYPE_GITLAB,
 } from '../../constants/platforms';
@@ -17,7 +18,10 @@ export default create({
         { hostname: options.hostname },
         'Converting token to Bearer auth'
       );
-      if (options.hostType === PLATFORM_TYPE_GITHUB) {
+      if (
+        options.hostType === PLATFORM_TYPE_GITHUB ||
+        options.hostType === PLATFORM_TYPE_GITEA
+      ) {
         options.headers.authorization = `token ${options.token}`; // eslint-disable-line no-param-reassign
       } else if (options.hostType === PLATFORM_TYPE_GITLAB) {
         options.headers['Private-token'] = options.token; // eslint-disable-line no-param-reassign
diff --git a/test/platform/__snapshots__/index.spec.ts.snap b/test/platform/__snapshots__/index.spec.ts.snap
index 8b346faf5e..55090138c4 100644
--- a/test/platform/__snapshots__/index.spec.ts.snap
+++ b/test/platform/__snapshots__/index.spec.ts.snap
@@ -45,6 +45,51 @@ Array [
 ]
 `;
 
+exports[`platform has a list of supported methods for gitea 1`] = `
+Array [
+  "addAssignees",
+  "addReviewers",
+  "branchExists",
+  "cleanRepo",
+  "commitFilesToBranch",
+  "createPr",
+  "deleteBranch",
+  "deleteLabel",
+  "ensureComment",
+  "ensureCommentRemoval",
+  "ensureIssue",
+  "ensureIssueClosing",
+  "findIssue",
+  "findPr",
+  "getAllRenovateBranches",
+  "getBranchLastCommitTime",
+  "getBranchPr",
+  "getBranchStatus",
+  "getBranchStatusCheck",
+  "getCommitMessages",
+  "getFile",
+  "getFileList",
+  "getIssueList",
+  "getPr",
+  "getPrBody",
+  "getPrFiles",
+  "getPrList",
+  "getRepoForceRebase",
+  "getRepoStatus",
+  "getRepos",
+  "getVulnerabilityAlerts",
+  "initPlatform",
+  "initRepo",
+  "isBranchStale",
+  "mergeBranch",
+  "mergePr",
+  "setBaseBranch",
+  "setBranchPrefix",
+  "setBranchStatus",
+  "updatePr",
+]
+`;
+
 exports[`platform has a list of supported methods for github 1`] = `
 Array [
   "addAssignees",
diff --git a/test/platform/gitea/__snapshots__/index.spec.ts.snap b/test/platform/gitea/__snapshots__/index.spec.ts.snap
new file mode 100644
index 0000000000..7635572019
--- /dev/null
+++ b/test/platform/gitea/__snapshots__/index.spec.ts.snap
@@ -0,0 +1,169 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`platform/gitea createPr should use base branch by default 1`] = `
+Object {
+  "body": "pr-body",
+  "branchName": "pr-branch",
+  "canMerge": true,
+  "closedAt": "2017-12-28T12:17:48Z",
+  "createdAt": "2014-04-01T05:14:20Z",
+  "displayNumber": "Pull Request #42",
+  "isConflicted": false,
+  "isModified": undefined,
+  "isStale": undefined,
+  "number": 42,
+  "sha": "0d9c7726c3d628b7e28af234595cfd20febdbf8e",
+  "sourceRepo": "some/repo",
+  "state": "open",
+  "targetBranch": "devel",
+  "title": "pr-title",
+}
+`;
+
+exports[`platform/gitea createPr should use default branch if requested 1`] = `
+Object {
+  "body": "pr-body",
+  "branchName": "pr-branch",
+  "canMerge": true,
+  "closedAt": "2017-12-28T12:17:48Z",
+  "createdAt": "2014-04-01T05:14:20Z",
+  "displayNumber": "Pull Request #42",
+  "isConflicted": false,
+  "isModified": undefined,
+  "isStale": undefined,
+  "number": 42,
+  "sha": "0d9c7726c3d628b7e28af234595cfd20febdbf8e",
+  "sourceRepo": "some/repo",
+  "state": "open",
+  "targetBranch": "master",
+  "title": "pr-title",
+}
+`;
+
+exports[`platform/gitea getPr should fallback to direct fetching if cache fails 1`] = `
+Object {
+  "body": "some random pull request",
+  "branchName": "some-head-ref",
+  "canMerge": true,
+  "closedAt": null,
+  "createdAt": "2015-03-22T20:36:16Z",
+  "displayNumber": "Pull Request #1",
+  "isConflicted": false,
+  "isModified": true,
+  "isStale": false,
+  "number": 1,
+  "sha": "some-head-sha",
+  "sourceRepo": "some/repo",
+  "state": "open",
+  "targetBranch": "some-base-ref",
+  "title": "Some PR",
+}
+`;
+
+exports[`platform/gitea getPr should return enriched pull request which exists 1`] = `
+Object {
+  "body": "other random pull request",
+  "branchName": "other-head-ref",
+  "canMerge": true,
+  "closedAt": "2016-01-09T10:03:21Z",
+  "createdAt": "2011-08-18T22:30:38Z",
+  "displayNumber": "Pull Request #2",
+  "isConflicted": false,
+  "isModified": false,
+  "isStale": false,
+  "number": 2,
+  "sha": "other-head-sha",
+  "sourceRepo": "some/repo",
+  "state": "closed",
+  "targetBranch": "other-base-ref",
+  "title": "Other PR",
+}
+`;
+
+exports[`platform/gitea getPrList should return list of pull requests 1`] = `
+Array [
+  Object {
+    "body": "some random pull request",
+    "branchName": "some-head-ref",
+    "canMerge": true,
+    "closedAt": null,
+    "createdAt": "2015-03-22T20:36:16Z",
+    "displayNumber": "Pull Request #1",
+    "isConflicted": false,
+    "isModified": undefined,
+    "isStale": undefined,
+    "number": 1,
+    "sha": "some-head-sha",
+    "sourceRepo": "some/repo",
+    "state": "open",
+    "targetBranch": "some-base-ref",
+    "title": "Some PR",
+  },
+  Object {
+    "body": "other random pull request",
+    "branchName": "other-head-ref",
+    "canMerge": true,
+    "closedAt": "2016-01-09T10:03:21Z",
+    "createdAt": "2011-08-18T22:30:38Z",
+    "displayNumber": "Pull Request #2",
+    "isConflicted": false,
+    "isModified": undefined,
+    "isStale": undefined,
+    "number": 2,
+    "sha": "other-head-sha",
+    "sourceRepo": "some/repo",
+    "state": "closed",
+    "targetBranch": "other-base-ref",
+    "title": "Other PR",
+  },
+]
+`;
+
+exports[`platform/gitea getRepos should return an array of repos 1`] = `
+Array [
+  "a/b",
+  "c/d",
+]
+`;
+
+exports[`platform/gitea initPlatform() should support custom endpoint 1`] = `
+Object {
+  "endpoint": "https://gitea.renovatebot.com/",
+  "gitAuthor": "Renovate Bot <renovate@example.com>",
+}
+`;
+
+exports[`platform/gitea initPlatform() should support default endpoint 1`] = `
+Object {
+  "endpoint": "https://gitea.com/api/v1/",
+  "gitAuthor": "Renovate Bot <renovate@example.com>",
+}
+`;
+
+exports[`platform/gitea initPlatform() should use username as author name if full name is missing 1`] = `
+Object {
+  "endpoint": "https://gitea.com/api/v1/",
+  "gitAuthor": "renovate <renovate@example.com>",
+}
+`;
+
+exports[`platform/gitea initRepo should fall back to merge method "merge" 1`] = `
+Object {
+  "baseBranch": "master",
+  "isFork": false,
+}
+`;
+
+exports[`platform/gitea initRepo should fall back to merge method "rebase-merge" 1`] = `
+Object {
+  "baseBranch": "master",
+  "isFork": false,
+}
+`;
+
+exports[`platform/gitea initRepo should fall back to merge method "squash" 1`] = `
+Object {
+  "baseBranch": "master",
+  "isFork": false,
+}
+`;
diff --git a/test/platform/gitea/gitea-got-wrapper.spec.ts b/test/platform/gitea/gitea-got-wrapper.spec.ts
new file mode 100644
index 0000000000..bd1f5d1dfd
--- /dev/null
+++ b/test/platform/gitea/gitea-got-wrapper.spec.ts
@@ -0,0 +1,84 @@
+import { GotResponse } from '../../../lib/platform';
+import { partial } from '../../util';
+import { GotFn } from '../../../lib/util/got';
+import { GiteaGotApi } from '../../../lib/platform/gitea/gitea-got-wrapper';
+
+describe('platform/gitea/gitea-got-wrapper', () => {
+  let api: GiteaGotApi;
+  let got: jest.Mocked<GotFn> & jest.Mock;
+
+  const baseURL = 'https://gitea.renovatebot.com/api/v1';
+
+  beforeEach(async () => {
+    jest.resetAllMocks();
+    jest.mock('../../../lib/util/got');
+
+    api = (await import('../../../lib/platform/gitea/gitea-got-wrapper'))
+      .api as any;
+    got = (await import('../../../lib/util/got')).api as any;
+    api.setBaseUrl(baseURL);
+  });
+
+  it('supports responses without pagination when enabled', async () => {
+    got.mockResolvedValueOnce(
+      partial<GotResponse>({
+        body: { hello: 'world' },
+      })
+    );
+
+    const res = await api.get('pagination-example-1', { paginate: true });
+    expect(res.body).toEqual({ hello: 'world' });
+  });
+
+  it('supports root-level pagination', async () => {
+    got.mockResolvedValueOnce(
+      partial<GotResponse>({
+        body: ['abc', 'def', 'ghi'],
+        headers: { 'x-total-count': '5' },
+        url: `${baseURL}/pagination-example-1`,
+      })
+    );
+    got.mockResolvedValueOnce(
+      partial<GotResponse>({
+        body: ['jkl'],
+      })
+    );
+    got.mockResolvedValueOnce(
+      partial<GotResponse>({
+        body: ['mno', 'pqr'],
+      })
+    );
+
+    const res = await api.get('pagination-example-1', { paginate: true });
+
+    expect(res.body).toHaveLength(6);
+    expect(res.body).toEqual(['abc', 'def', 'ghi', 'jkl', 'mno', 'pqr']);
+    expect(got).toHaveBeenCalledTimes(3);
+  });
+
+  it('supports pagination on data property', async () => {
+    got.mockResolvedValueOnce(
+      partial<GotResponse>({
+        body: { data: ['abc', 'def', 'ghi'] },
+        headers: { 'x-total-count': '5' },
+        url: `${baseURL}/pagination-example-2`,
+      })
+    );
+    got.mockResolvedValueOnce(
+      partial<GotResponse>({
+        body: { data: ['jkl'] },
+      })
+    );
+    got.mockResolvedValueOnce(
+      partial<GotResponse>({
+        body: { data: ['mno', 'pqr'] },
+      })
+    );
+
+    const res = await api.get('pagination-example-2', { paginate: true });
+
+    expect(res.body.data).toHaveLength(6);
+    expect(res.body.data).toEqual(['abc', 'def', 'ghi', 'jkl', 'mno', 'pqr']);
+    expect(got).toHaveBeenCalledTimes(3);
+  });
+});
diff --git a/test/platform/gitea/gitea-helper.spec.ts b/test/platform/gitea/gitea-helper.spec.ts
new file mode 100644
index 0000000000..5ff1cf830a
--- /dev/null
+++ b/test/platform/gitea/gitea-helper.spec.ts
@@ -0,0 +1,836 @@
+import { URL } from 'url';
+import { GotResponse } from '../../../lib/platform';
+import { partial } from '../../util';
+import {
+  GiteaGotApi,
+  GiteaGotOptions,
+} from '../../../lib/platform/gitea/gitea-got-wrapper';
+import * as ght from '../../../lib/platform/gitea/gitea-helper';
+import { PRSearchParams } from '../../../lib/platform/gitea/gitea-helper';
+
+describe('platform/gitea/gitea-helper', () => {
+  let helper: typeof import('../../../lib/platform/gitea/gitea-helper');
+  let api: jest.Mocked<GiteaGotApi>;
+
+  const baseURL = 'https://gitea.renovatebot.com/api/v1';
+
+  const mockCommitHash = '0d9c7726c3d628b7e28af234595cfd20febdbf8e';
+
+  const mockUser: ght.User = {
+    id: 1,
+    username: 'admin',
+    full_name: 'The Administrator',
+    email: 'admin@example.com',
+  };
+
+  const otherMockUser: ght.User = {
+    ...mockUser,
+    username: 'renovate',
+    full_name: 'Renovate Bot',
+    email: 'renovate@example.com',
+  };
+
+  const mockRepo: ght.Repo = {
+    allow_rebase: true,
+    allow_rebase_explicit: true,
+    allow_merge_commits: true,
+    allow_squash_merge: true,
+    clone_url: 'https://gitea.renovatebot.com/some/repo.git',
+    default_branch: 'master',
+    full_name: 'some/repo',
+    archived: false,
+    mirror: false,
+    empty: false,
+    fork: false,
+    owner: mockUser,
+    permissions: {
+      pull: true,
+      push: true,
+      admin: false,
+    },
+  };
+
+  const otherMockRepo: ght.Repo = {
+    ...mockRepo,
+    full_name: 'other/repo',
+    clone_url: 'https://gitea.renovatebot.com/other/repo.git',
+  };
+
+  const mockLabel: ght.Label = {
+    id: 100,
+    name: 'some-label',
+    description: 'just a label',
+    color: '#000000',
+  };
+
+  const otherMockLabel: ght.Label = {
+    ...mockLabel,
+    id: 200,
+    name: 'other-label',
+  };
+
+  const mockPR: ght.PR = {
+    number: 13,
+    state: 'open',
+    title: 'Some PR',
+    body: 'Lorem ipsum dolor sit amet',
+    mergeable: true,
+    diff_url: `https://gitea.renovatebot.com/${mockRepo.full_name}/pulls/13.diff`,
+    base: { ref: mockRepo.default_branch },
+    head: {
+      ref: 'pull-req-13',
+      sha: mockCommitHash,
+      repo: mockRepo,
+    },
+    created_at: '2018-08-13T20:45:37Z',
+    closed_at: '2020-04-01T19:19:22Z',
+  };
+
+  const mockIssue: ght.Issue = {
+    number: 7,
+    state: 'open',
+    title: 'Some Issue',
+    body: 'just some issue',
+    assignees: [mockUser],
+  };
+
+  const mockComment: ght.Comment = {
+    id: 31,
+    body: 'some-comment',
+  };
+
+  const mockCommitStatus: ght.CommitStatus = {
+    id: 121,
+    status: 'success',
+    context: 'some-context',
+    description: 'some-description',
+    target_url: 'https://gitea.renovatebot.com/commit-status',
+  };
+
+  const otherMockCommitStatus: ght.CommitStatus = {
+    ...mockCommitStatus,
+    id: 242,
+    status: 'error',
+    context: 'other-context',
+  };
+
+  const mockCommit: ght.Commit = {
+    id: mockCommitHash,
+    author: {
+      name: otherMockUser.full_name,
+      email: otherMockUser.email,
+      username: otherMockUser.username,
+    },
+  };
+
+  const mockBranch: ght.Branch = {
+    name: 'some-branch',
+    commit: mockCommit,
+  };
+
+  const otherMockBranch: ght.Branch = {
+    ...mockBranch,
+    name: 'other/branch/with/slashes',
+  };
+
+  const mockContents: ght.RepoContents = {
+    path: 'dummy.txt',
+    content: Buffer.from('top secret').toString('base64'),
+    contentString: 'top secret',
+  };
+
+  const otherMockContents: ght.RepoContents = {
+    ...mockContents,
+    path: 'nested/path/dummy.txt',
+  };
+
+  const mockAPI = <B extends object = undefined, P extends object = {}>(
+    userOptions: {
+      method?: 'get' | 'post' | 'put' | 'patch' | 'head' | 'delete';
+      urlPattern?: string | RegExp;
+      queryParams?: Record<string, string[]>;
+      postParams?: P;
+    },
+    body: B = undefined
+  ) => {
+    // Merge default options with user options
+    const options = {
+      method: 'get',
+      ...userOptions,
+    };
+
+    // Mock request implementation once and verify request
+    api[options.method].mockImplementationOnce(
+      (rawUrl: string, apiOpts?: GiteaGotOptions): Promise<GotResponse<B>> => {
+        // Construct and parse absolute URL
+        const absoluteUrl = rawUrl.includes('://')
+          ? rawUrl
+          : `${baseURL}/${rawUrl}`;
+        const url = new URL(absoluteUrl);
+
+        // Check optional URL pattern matcher
+        if (options.urlPattern !== undefined) {
+          const regex =
+            options.urlPattern instanceof RegExp
+              ? options.urlPattern
+              : new RegExp(`^${options.urlPattern}$`);
+
+          if (!regex.exec(url.pathname)) {
+            throw new Error(
+              `expected url [${url.pathname}] to match pattern: ${options.urlPattern}`
+            );
+          }
+        }
+
+        // Check optional query params
+        if (options.queryParams !== undefined) {
+          for (const [key, expected] of Object.entries(options.queryParams)) {
+            expect(url.searchParams.getAll(key)).toEqual(expected);
+          }
+        }
+
+        // Check optional post parameters
+        if (options.postParams !== undefined) {
+          expect(apiOpts.body).toEqual(options.postParams);
+        }
+
+        return Promise.resolve(
+          partial<GotResponse<B>>({ body })
+        );
+      }
+    );
+  };
+
+  beforeEach(async () => {
+    jest.resetAllMocks();
+    jest.mock('../../../lib/platform/gitea/gitea-got-wrapper');
+
+    helper = (await import('../../../lib/platform/gitea/gitea-helper')) as any;
+    api = (await import('../../../lib/platform/gitea/gitea-got-wrapper'))
+      .api as any;
+  });
+
+  describe('getCurrentUser', () => {
+    it('should call /api/v1/user endpoint', async () => {
+      mockAPI<ght.User>({ urlPattern: '/api/v1/user' }, mockUser);
+
+      const res = await helper.getCurrentUser();
+      expect(res).toEqual(mockUser);
+    });
+  });
+
+  describe('searchRepos', () => {
+    it('should call /api/v1/repos/search endpoint', async () => {
+      mockAPI<ght.RepoSearchResults>(
+        { urlPattern: '/api/v1/repos/search' },
+        {
+          ok: true,
+          data: [mockRepo, otherMockRepo],
+        }
+      );
+
+      const res = await helper.searchRepos({});
+      expect(res).toEqual([mockRepo, otherMockRepo]);
+    });
+
+    it('should construct proper query parameters', async () => {
+      mockAPI<ght.RepoSearchResults>(
+        {
+          urlPattern: '/api/v1/repos/search',
+          queryParams: {
+            uid: ['13'],
+          },
+        },
+        {
+          ok: true,
+          data: [otherMockRepo],
+        }
+      );
+
+      const res = await helper.searchRepos({
+        uid: 13,
+      });
+      expect(res).toEqual([otherMockRepo]);
+    });
+
+    it('should abort if ok flag was not set', async () => {
+      mockAPI<ght.RepoSearchResults>(
+        { urlPattern: '/api/v1/repos/search' },
+        {
+          ok: false,
+          data: [],
+        }
+      );
+
+      await expect(helper.searchRepos({})).rejects.toThrow();
+    });
+  });
+
+  describe('getRepo', () => {
+    it('should call /api/v1/repos/[repo] endpoint', async () => {
+      mockAPI<ght.Repo>(
+        { urlPattern: `/api/v1/repos/${mockRepo.full_name}` },
+        mockRepo
+      );
+
+      const res = await helper.getRepo(mockRepo.full_name);
+      expect(res).toEqual(mockRepo);
+    });
+  });
+
+  describe('getRepoContents', () => {
+    it('should call /api/v1/repos/[repo]/contents/[file] endpoint', async () => {
+      // The official API only returns the base64-encoded content, so we strip `contentString`
+      // from our mock to verify base64 decoding.
+      mockAPI<ght.RepoContents>(
+        {
+          urlPattern: `/api/v1/repos/${mockRepo.full_name}/contents/${mockContents.path}`,
+        },
+        { ...mockContents, contentString: undefined }
+      );
+
+      const res = await helper.getRepoContents(
+        mockRepo.full_name,
+        mockContents.path
+      );
+      expect(res).toEqual(mockContents);
+    });
+
+    it('should support passing reference by query', async () => {
+      mockAPI<ght.RepoContents>(
+        {
+          urlPattern: `/api/v1/repos/${mockRepo.full_name}/contents/${mockContents.path}`,
+          queryParams: {
+            ref: [mockCommitHash],
+          },
+        },
+        { ...mockContents, contentString: undefined }
+      );
+
+      const res = await helper.getRepoContents(
+        mockRepo.full_name,
+        mockContents.path,
+        mockCommitHash
+      );
+      expect(res).toEqual(mockContents);
+    });
+
+    it('should properly escape paths', async () => {
+      const escapedPath = encodeURIComponent(otherMockContents.path);
+
+      mockAPI<ght.RepoContents>(
+        {
+          urlPattern: `/api/v1/repos/${mockRepo.full_name}/contents/${escapedPath}`,
+        },
+        otherMockContents
+      );
+
+      const res = await helper.getRepoContents(
+        mockRepo.full_name,
+        otherMockContents.path
+      );
+      expect(res).toEqual(otherMockContents);
+    });
+
+    it('should not fail if no content is returned', async () => {
+      mockAPI<ght.RepoContents>(
+        {
+          urlPattern: `/api/v1/repos/${mockRepo.full_name}/contents/${mockContents.path}`,
+        },
+        { ...mockContents, content: undefined, contentString: undefined }
+      );
+
+      const res = await helper.getRepoContents(
+        mockRepo.full_name,
+        mockContents.path
+      );
+      expect(res).toEqual({
+        ...mockContents,
+        content: undefined,
+        contentString: undefined,
+      });
+    });
+  });
+
+  describe('createPR', () => {
+    it('should call /api/v1/repos/[repo]/pulls endpoint', async () => {
+      mockAPI<ght.PR, Required<ght.PRCreateParams>>(
+        {
+          method: 'post',
+          urlPattern: `/api/v1/repos/${mockRepo.full_name}/pulls`,
+          postParams: {
+            state: mockPR.state,
+            title: mockPR.title,
+            body: mockPR.body,
+            base: mockPR.base.ref,
+            head: mockPR.head.ref,
+            assignees: [mockUser.username],
+            labels: [mockLabel.id],
+          },
+        },
+        mockPR
+      );
+
+      const res = await helper.createPR(mockRepo.full_name, {
+        state: mockPR.state,
+        title: mockPR.title,
+        body: mockPR.body,
+        base: mockPR.base.ref,
+        head: mockPR.head.ref,
+        assignees: [mockUser.username],
+        labels: [mockLabel.id],
+      });
+      expect(res).toEqual(mockPR);
+    });
+  });
+
+  describe('updatePR', () => {
+    it('should call /api/v1/repos/[repo]/pulls/[pull] endpoint', async () => {
+      const updatedMockPR: ght.PR = {
+        ...mockPR,
+        state: 'closed',
+        title: 'new-title',
+        body: 'new-body',
+      };
+
+      mockAPI<ght.PR, Required<ght.PRUpdateParams>>(
+        {
+          method: 'patch',
+          urlPattern: `/api/v1/repos/${mockRepo.full_name}/pulls/${mockPR.number}`,
+          postParams: {
+            state: 'closed',
+            title: 'new-title',
+            body: 'new-body',
+            assignees: [otherMockUser.username],
+            labels: [otherMockLabel.id],
+          },
+        },
+        updatedMockPR
+      );
+
+      const res = await helper.updatePR(mockRepo.full_name, mockPR.number, {
+        state: 'closed',
+        title: 'new-title',
+        body: 'new-body',
+        assignees: [otherMockUser.username],
+        labels: [otherMockLabel.id],
+      });
+      expect(res).toEqual(updatedMockPR);
+    });
+  });
+
+  describe('closePR', () => {
+    it('should call /api/v1/repos/[repo]/pulls/[pull] endpoint', async () => {
+      mockAPI<undefined, ght.PRUpdateParams>({
+        method: 'patch',
+        urlPattern: `/api/v1/repos/${mockRepo.full_name}/pulls/${mockPR.number}`,
+        postParams: {
+          state: 'closed',
+        },
+      });
+
+      const res = await helper.closePR(mockRepo.full_name, mockPR.number);
+      expect(res).toBeUndefined();
+    });
+  });
+
+  describe('mergePR', () => {
+    it('should call /api/v1/repos/[repo]/pulls/[pull]/merge endpoint', async () => {
+      mockAPI<undefined, ght.PRMergeParams>({
+        method: 'patch',
+        urlPattern: `/api/v1/repos/${mockRepo.full_name}/pulls/${mockPR.number}/merge`,
+        postParams: {
+          Do: 'rebase',
+        },
+      });
+
+      const res = await helper.mergePR(
+        mockRepo.full_name,
+        mockPR.number,
+        'rebase'
+      );
+      expect(res).toBeUndefined();
+    });
+  });
+
+  describe('getPR', () => {
+    it('should call /api/v1/repos/[repo]/pulls/[pull] endpoint', async () => {
+      mockAPI<ght.PR>(
+        {
+          urlPattern: `/api/v1/repos/${mockRepo.full_name}/pulls/${mockPR.number}`,
+        },
+        mockPR
+      );
+
+      const res = await helper.getPR(mockRepo.full_name, mockPR.number);
+      expect(res).toEqual(mockPR);
+    });
+  });
+
+  describe('searchPRs', () => {
+    it('should call /api/v1/repos/[repo]/pulls endpoint', async () => {
+      mockAPI<ght.PR[]>(
+        {
+          urlPattern: `/api/v1/repos/${mockRepo.full_name}/pulls`,
+        },
+        [mockPR]
+      );
+
+      const res = await helper.searchPRs(mockRepo.full_name, {});
+      expect(res).toEqual([mockPR]);
+    });
+
+    it('should construct proper query parameters', async () => {
+      mockAPI<ght.PR[], Required<PRSearchParams>>(
+        {
+          urlPattern: `/api/v1/repos/${mockRepo.full_name}/pulls`,
+          queryParams: {
+            state: ['open'],
+            labels: [`${mockLabel.id}`, `${otherMockLabel.id}`],
+          },
+        },
+        [mockPR]
+      );
+
+      const res = await helper.searchPRs(mockRepo.full_name, {
+        state: 'open',
+        labels: [mockLabel.id, otherMockLabel.id],
+      });
+      expect(res).toEqual([mockPR]);
+    });
+  });
+
+  describe('createIssue', () => {
+    it('should call /api/v1/repos/[repo]/issues endpoint', async () => {
+      mockAPI<ght.Issue, Required<ght.IssueCreateParams>>(
+        {
+          method: 'post',
+          urlPattern: `/api/v1/repos/${mockRepo.full_name}/issues`,
+          postParams: {
+            state: mockIssue.state,
+            title: mockIssue.title,
+            body: mockIssue.body,
+            assignees: [mockUser.username],
+          },
+        },
+        mockIssue
+      );
+
+      const res = await helper.createIssue(mockRepo.full_name, {
+        state: mockIssue.state,
+        title: mockIssue.title,
+        body: mockIssue.body,
+        assignees: [mockUser.username],
+      });
+      expect(res).toEqual(mockIssue);
+    });
+  });
+
+  describe('updateIssue', () => {
+    it('should call /api/v1/repos/[repo]/issues/[issue] endpoint', async () => {
+      const updatedMockIssue: ght.Issue = {
+        ...mockIssue,
+        state: 'closed',
+        title: 'new-title',
+        body: 'new-body',
+        assignees: [otherMockUser],
+      };
+
+      mockAPI<ght.Issue, Required<ght.IssueUpdateParams>>(
+        {
+          method: 'patch',
+          urlPattern: `/api/v1/repos/${mockRepo.full_name}/issues/${mockIssue.number}`,
+          postParams: {
+            state: 'closed',
+            title: 'new-title',
+            body: 'new-body',
+            assignees: [otherMockUser.username],
+          },
+        },
+        updatedMockIssue
+      );
+
+      const res = await helper.updateIssue(
+        mockRepo.full_name,
+        mockIssue.number,
+        {
+          state: 'closed',
+          title: 'new-title',
+          body: 'new-body',
+          assignees: [otherMockUser.username],
+        }
+      );
+      expect(res).toEqual(updatedMockIssue);
+    });
+  });
+
+  describe('closeIssue', () => {
+    it('should call /api/v1/repos/[repo]/issues/[issue] endpoint', async () => {
+      mockAPI<ght.IssueUpdateParams>({
+        method: 'patch',
+        urlPattern: `/api/v1/repos/${mockRepo.full_name}/issues/${mockIssue.number}`,
+        postParams: {
+          state: 'closed',
+        },
+      });
+
+      const res = await helper.closeIssue(mockRepo.full_name, mockIssue.number);
+      expect(res).toBeUndefined();
+    });
+  });
+
+  describe('searchIssues', () => {
+    it('should call /api/v1/repos/[repo]/issues endpoint', async () => {
+      mockAPI<ght.Issue[]>(
+        {
+          urlPattern: `/api/v1/repos/${mockRepo.full_name}/issues`,
+        },
+        [mockIssue]
+      );
+
+      const res = await helper.searchIssues(mockRepo.full_name, {});
+      expect(res).toEqual([mockIssue]);
+    });
+
+    it('should construct proper query parameters', async () => {
+      mockAPI<ght.Issue[], Required<PRSearchParams>>(
+        {
+          urlPattern: `/api/v1/repos/${mockRepo.full_name}/issues`,
+          queryParams: {
+            state: ['open'],
+          },
+        },
+        [mockIssue]
+      );
+
+      const res = await helper.searchIssues(mockRepo.full_name, {
+        state: 'open',
+      });
+      expect(res).toEqual([mockIssue]);
+    });
+  });
+
+  describe('getRepoLabels', () => {
+    it('should call /api/v1/repos/[repo]/labels endpoint', async () => {
+      mockAPI<ght.Label[]>(
+        {
+          urlPattern: `/api/v1/repos/${mockRepo.full_name}/labels`,
+        },
+        [mockLabel, otherMockLabel]
+      );
+
+      const res = await helper.getRepoLabels(mockRepo.full_name);
+      expect(res).toEqual([mockLabel, otherMockLabel]);
+    });
+  });
+
+  describe('unassignLabel', () => {
+    it('should call /api/v1/repos/[repo]/issues/[issue]/labels/[label] endpoint', async () => {
+      mockAPI({
+        method: 'delete',
+        urlPattern: `/api/v1/repos/${mockRepo.full_name}/issues/${mockIssue.number}/labels/${mockLabel.id}`,
+      });
+
+      const res = await helper.unassignLabel(
+        mockRepo.full_name,
+        mockIssue.number,
+        mockLabel.id
+      );
+      expect(res).toBeUndefined();
+    });
+  });
+
+  describe('createComment', () => {
+    it('should call /api/v1/repos/[repo]/issues/[issue]/comments endpoint', async () => {
+      mockAPI<ght.Comment, Required<ght.CommentCreateParams>>(
+        {
+          method: 'post',
+          urlPattern: `/api/v1/repos/${mockRepo.full_name}/issues/${mockIssue.number}/comments`,
+          postParams: {
+            body: mockComment.body,
+          },
+        },
+        mockComment
+      );
+
+      const res = await helper.createComment(
+        mockRepo.full_name,
+        mockIssue.number,
+        mockComment.body
+      );
+      expect(res).toEqual(mockComment);
+    });
+  });
+
+  describe('updateComment', () => {
+    it('should call /api/v1/repos/[repo]/issues/comments/[comment] endpoint', async () => {
+      const updatedMockComment: ght.Comment = {
+        ...mockComment,
+        body: 'new-body',
+      };
+
+      mockAPI<ght.Comment, Required<ght.CommentUpdateParams>>(
+        {
+          method: 'patch',
+          urlPattern: `/api/v1/repos/${mockRepo.full_name}/issues/comments/${mockComment.id}`,
+          postParams: {
+            body: 'new-body',
+          },
+        },
+        updatedMockComment
+      );
+
+      const res = await helper.updateComment(
+        mockRepo.full_name,
+        mockComment.id,
+        'new-body'
+      );
+      expect(res).toEqual(updatedMockComment);
+    });
+  });
+
+  describe('deleteComment', () => {
+    it('should call /api/v1/repos/[repo]/issues/comments/[comment] endpoint', async () => {
+      mockAPI({
+        method: 'delete',
+        urlPattern: `/api/v1/repos/${mockRepo.full_name}/issues/comments/${mockComment.id}`,
+      });
+
+      const res = await helper.deleteComment(
+        mockRepo.full_name,
+        mockComment.id
+      );
+      expect(res).toBeUndefined();
+    });
+  });
+
+  describe('getComments', () => {
+    it('should call /api/v1/repos/[repo]/issues/[issue]/comments endpoint', async () => {
+      mockAPI<ght.Comment[]>(
+        {
+          urlPattern: `/api/v1/repos/${mockRepo.full_name}/issues/${mockIssue.number}/comments`,
+        },
+        [mockComment]
+      );
+
+      const res = await helper.getComments(
+        mockRepo.full_name,
+        mockIssue.number
+      );
+      expect(res).toEqual([mockComment]);
+    });
+  });
+
+  describe('createCommitStatus', () => {
+    it('should call /api/v1/repos/[repo]/statuses/[commit] endpoint', async () => {
+      mockAPI<ght.CommitStatus, Required<ght.CommitStatusCreateParams>>(
+        {
+          method: 'post',
+          urlPattern: `/api/v1/repos/${mockRepo.full_name}/statuses/${mockCommitHash}`,
+          postParams: {
+            state: mockCommitStatus.status,
+            context: mockCommitStatus.context,
+            description: mockCommitStatus.description,
+            target_url: mockCommitStatus.target_url,
+          },
+        },
+        mockCommitStatus
+      );
+
+      const res = await helper.createCommitStatus(
+        mockRepo.full_name,
+        mockCommitHash,
+        {
+          state: mockCommitStatus.status,
+          context: mockCommitStatus.context,
+          description: mockCommitStatus.description,
+          target_url: mockCommitStatus.target_url,
+        }
+      );
+      expect(res).toEqual(mockCommitStatus);
+    });
+  });
+
+  describe('getCombinedCommitStatus', () => {
+    it('should call /api/v1/repos/[repo]/commits/[branch]/statuses endpoint', async () => {
+      mockAPI<ght.CommitStatus[]>(
+        {
+          urlPattern: `/api/v1/repos/${mockRepo.full_name}/commits/${mockBranch.name}/statuses`,
+        },
+        [mockCommitStatus, otherMockCommitStatus]
+      );
+
+      const res = await helper.getCombinedCommitStatus(
+        mockRepo.full_name,
+        mockBranch.name
+      );
+      expect(res.worstStatus).not.toEqual('unknown');
+      expect(res.statuses).toEqual([mockCommitStatus, otherMockCommitStatus]);
+    });
+
+    it('should properly determine worst commit status', async () => {
+      const statuses: ght.CommitStatusType[] = [
+        'unknown',
+        'success',
+        'pending',
+        'warning',
+        'failure',
+        'error',
+      ];
+
+      const commitStatuses: ght.CommitStatus[] = [
+        { ...mockCommitStatus, status: 'unknown' },
+      ];
+
+      for (const status of statuses) {
+        // Add current status ot list of commit statuses, then mock the API to return the whole list
+        commitStatuses.push({ ...mockCommitStatus, status });
+        mockAPI<ght.CommitStatus[]>(
+          {
+            urlPattern: `/api/v1/repos/${mockRepo.full_name}/commits/${mockBranch.name}/statuses`,
+          },
+          commitStatuses
+        );
+
+        // Expect to get the current state back as the worst status, as all previous commit statuses
+        // should be less important than the one which just got added
+        const res = await helper.getCombinedCommitStatus(
+          mockRepo.full_name,
+          mockBranch.name
+        );
+        expect(res.worstStatus).toEqual(status);
+      }
+    });
+  });
+
+  describe('getBranch', () => {
+    it('should call /api/v1/repos/[repo]/branches/[branch] endpoint', async () => {
+      mockAPI<ght.Branch>(
+        {
+          urlPattern: `/api/v1/repos/${mockRepo.full_name}/branches/${mockBranch.name}`,
+        },
+        mockBranch
+      );
+
+      const res = await helper.getBranch(mockRepo.full_name, mockBranch.name);
+      expect(res).toEqual(mockBranch);
+    });
+
+    it('should properly escape branch names', async () => {
+      const escapedBranchName = encodeURIComponent(otherMockBranch.name);
+
+      mockAPI<ght.Branch>(
+        {
+          urlPattern: `/api/v1/repos/${mockRepo.full_name}/branches/${escapedBranchName}`,
+        },
+        otherMockBranch
+      );
+
+      const res = await helper.getBranch(
+        mockRepo.full_name,
+        otherMockBranch.name
+      );
+      expect(res).toEqual(otherMockBranch);
+    });
+  });
+});
diff --git a/test/platform/gitea/index.spec.ts b/test/platform/gitea/index.spec.ts
new file mode 100644
index 0000000000..a280c96a1f
--- /dev/null
+++ b/test/platform/gitea/index.spec.ts
@@ -0,0 +1,1550 @@
+import { partial } from '../../util';
+import * as ght from '../../../lib/platform/gitea/gitea-helper';
+import {
+  REPOSITORY_ACCESS_FORBIDDEN,
+  REPOSITORY_ARCHIVED,
+  REPOSITORY_BLOCKED,
+  REPOSITORY_CHANGED,
+  REPOSITORY_DISABLED,
+  REPOSITORY_EMPTY,
+  REPOSITORY_MIRRORED,
+} from '../../../lib/constants/error-messages';
+import {
+  BranchStatus,
+  BranchStatusConfig,
+  GotResponse,
+  RepoConfig,
+  RepoParams,
+} from '../../../lib/platform';
+import { logger as _logger } from '../../../lib/logger';
+import {
+  BRANCH_STATUS_FAILED,
+  BRANCH_STATUS_PENDING,
+  BRANCH_STATUS_SUCCESS,
+} from '../../../lib/constants/branch-constants';
+import { GiteaGotApi } from '../../../lib/platform/gitea/gitea-got-wrapper';
+import { CommitFilesConfig, File } from '../../../lib/platform/git/storage';
+
+describe('platform/gitea', () => {
+  let gitea: typeof import('../../../lib/platform/gitea');
+  let helper: jest.Mocked<typeof import('../../../lib/platform/gitea/gitea-helper')>;
+  let api: jest.Mocked<GiteaGotApi>;
+  let logger: jest.Mocked<typeof _logger>;
+  let GitStorage: jest.Mocked<
+    typeof import('../../../lib/platform/git/storage').Storage
+  > &
+    jest.Mock;
+
+  const mockCommitHash = '0d9c7726c3d628b7e28af234595cfd20febdbf8e';
+
+  const mockUser: ght.User = {
+    id: 1,
+    username: 'renovate',
+    full_name: 'Renovate Bot',
+    email: 'renovate@example.com',
+  };
+
+  const mockRepo = partial<ght.Repo>({
+    allow_rebase: true,
+    clone_url: 'https://gitea.renovatebot.com/some/repo.git',
+    default_branch: 'master',
+    full_name: 'some/repo',
+    permissions: {
+      pull: true,
+      push: true,
+      admin: false,
+    },
+  });
+
+  const mockRepos: ght.Repo[] = [
+    partial<ght.Repo>({ full_name: 'a/b' }),
+    partial<ght.Repo>({ full_name: 'c/d' }),
+  ];
+
+  const mockPRs: ght.PR[] = [
+    partial<ght.PR>({
+      number: 1,
+      title: 'Some PR',
+      body: 'some random pull request',
+      state: 'open',
+      diff_url: 'https://gitea.renovatebot.com/some/repo/pulls/1.diff',
+      created_at: '2015-03-22T20:36:16Z',
+      closed_at: null,
+      mergeable: true,
+      base: { ref: 'some-base-ref' },
+      head: {
+        ref: 'some-head-ref',
+        sha: 'some-head-sha',
+        repo: partial<ght.Repo>({ full_name: mockRepo.full_name }),
+      },
+    }),
+    partial<ght.PR>({
+      number: 2,
+      title: 'Other PR',
+      body: 'other random pull request',
+      state: '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-ref' },
+      head: {
+        ref: 'other-head-ref',
+        sha: 'other-head-sha',
+        repo: partial<ght.Repo>({ full_name: mockRepo.full_name }),
+      },
+    }),
+  ];
+
+  const mockIssues: ght.Issue[] = [
+    {
+      number: 1,
+      title: 'open-issue',
+      state: 'open',
+      body: 'some-content',
+      assignees: [],
+    },
+    {
+      number: 2,
+      title: 'closed-issue',
+      state: 'closed',
+      body: 'other-content',
+      assignees: [],
+    },
+    {
+      number: 3,
+      title: 'duplicate-issue',
+      state: 'open',
+      body: 'duplicate-content',
+      assignees: [],
+    },
+    {
+      number: 4,
+      title: 'duplicate-issue',
+      state: 'open',
+      body: 'duplicate-content',
+      assignees: [],
+    },
+    {
+      number: 5,
+      title: 'duplicate-issue',
+      state: 'open',
+      body: 'duplicate-content',
+      assignees: [],
+    },
+  ];
+
+  const mockComments: ght.Comment[] = [
+    { id: 1, body: 'some-body' },
+    { id: 2, body: 'other-body' },
+    { id: 3, body: '### some-topic\n\nsome-content' },
+  ];
+
+  const mockLabels: ght.Label[] = [
+    { id: 1, name: 'some-label', description: 'its a me', color: '#000000' },
+    { id: 2, name: 'other-label', description: 'labelario', color: '#ffffff' },
+  ];
+
+  const gsmInitRepo = jest.fn();
+  const gsmCleanRepo = jest.fn();
+  const gsmSetBaseBranch = jest.fn();
+  const gsmGetCommitMessages = jest.fn();
+  const gsmGetAllRenovateBranches = jest.fn();
+  const gsmGetFileList = jest.fn();
+  const gsmGetRepoStatus = jest.fn();
+  const gsmGetFile = jest.fn();
+  const gsmGetBranchLastCommitTime = jest.fn();
+  const gsmMergeBranch = jest.fn();
+  const gsmBranchExists = jest.fn();
+  const gsmSetBranchPrefix = jest.fn();
+  const gsmCommitFilesToBranch = jest.fn();
+  const gsmDeleteBranch = jest.fn();
+  const gsmIsBranchStale = jest.fn(() => false);
+  const gsmGetBranchCommit = jest.fn(() => mockCommitHash);
+
+  beforeEach(async () => {
+    jest.resetModules();
+    jest.clearAllMocks();
+    jest.mock('../../../lib/platform/gitea/gitea-helper');
+    jest.mock('../../../lib/platform/gitea/gitea-got-wrapper');
+    jest.mock('../../../lib/platform/git/storage');
+    jest.mock('../../../lib/logger');
+
+    gitea = await import('../../../lib/platform/gitea');
+    helper = (await import('../../../lib/platform/gitea/gitea-helper')) as any;
+    api = (await import('../../../lib/platform/gitea/gitea-got-wrapper'))
+      .api as any;
+    logger = (await import('../../../lib/logger')).logger as any;
+    GitStorage = (await import('../../../lib/platform/git/storage'))
+      .Storage as any;
+
+    GitStorage.mockImplementation(() => ({
+      initRepo: gsmInitRepo,
+      cleanRepo: gsmCleanRepo,
+      setBaseBranch: gsmSetBaseBranch,
+      getCommitMessages: gsmGetCommitMessages,
+      getAllRenovateBranches: gsmGetAllRenovateBranches,
+      getFileList: gsmGetFileList,
+      getRepoStatus: gsmGetRepoStatus,
+      getFile: gsmGetFile,
+      getBranchLastCommitTime: gsmGetBranchLastCommitTime,
+      mergeBranch: gsmMergeBranch,
+      branchExists: gsmBranchExists,
+      setBranchPrefix: gsmSetBranchPrefix,
+      isBranchStale: gsmIsBranchStale,
+      getBranchCommit: gsmGetBranchCommit,
+      commitFilesToBranch: gsmCommitFilesToBranch,
+      deleteBranch: gsmDeleteBranch,
+    }));
+
+    global.gitAuthor = { name: 'Renovate', email: 'renovate@example.com' };
+  });
+
+  function initFakeRepo(
+    repo?: Partial<ght.Repo>,
+    config?: Partial<RepoParams>
+  ): Promise<RepoConfig> {
+    helper.getRepo.mockResolvedValueOnce({ ...mockRepo, ...repo });
+
+    return gitea.initRepo({
+      repository: mockRepo.full_name,
+      localDir: '',
+      optimizeForDisabled: false,
+      ...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 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).toMatchSnapshot();
+    });
+  });
+
+  describe('initRepo', () => {
+    const initRepoCfg: RepoParams = {
+      repository: mockRepo.full_name,
+      localDir: '',
+      optimizeForDisabled: false,
+    };
+
+    it('should propagate API errors', async () => {
+      helper.getRepo.mockRejectedValueOnce(new Error('getRepo()'));
+
+      await expect(gitea.initRepo(initRepoCfg)).rejects.toThrow('getRepo()');
+    });
+
+    it('should abort when disabled and optimizeForDisabled is enabled', async () => {
+      helper.getRepoContents.mockResolvedValueOnce(
+        partial<ght.RepoContents>({
+          contentString: JSON.stringify({ enabled: false }),
+        })
+      );
+
+      await expect(
+        initFakeRepo({}, { optimizeForDisabled: true })
+      ).rejects.toThrow(REPOSITORY_DISABLED);
+    });
+
+    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();
+    });
+  });
+
+  describe('cleanRepo', () => {
+    it('does not throw an error with uninitialized repo', async () => {
+      await gitea.cleanRepo();
+      expect(gsmCleanRepo).not.toHaveBeenCalled();
+    });
+
+    it('propagates call to storage class with initialized repo', async () => {
+      await initFakeRepo();
+      await gitea.cleanRepo();
+      expect(gsmCleanRepo).toHaveBeenCalledTimes(1);
+    });
+  });
+
+  describe('setBranchStatus', () => {
+    const setBranchStatus = async (bsc?: Partial<BranchStatusConfig>) => {
+      await initFakeRepo();
+      await gitea.setBranchStatus({
+        branchName: 'some-branch',
+        state: 'some-state',
+        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: 'some-state',
+          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: 'some-state',
+          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<ght.CombinedCommitStatus>({
+          worstStatus: state as ght.CommitStatusType,
+        })
+      );
+
+      return gitea.getBranchStatus('some-branch', []);
+    };
+
+    it('should return success if requiredStatusChecks null', async () => {
+      expect(await gitea.getBranchStatus('some-branch', null)).toEqual(
+        BRANCH_STATUS_SUCCESS
+      );
+    });
+
+    it('should return failed if unsupported requiredStatusChecks', async () => {
+      expect(await gitea.getBranchStatus('some-branch', ['foo'])).toEqual(
+        BRANCH_STATUS_FAILED
+      );
+    });
+
+    it('should return pending state for unknown result', async () => {
+      expect(await getBranchStatus('unknown')).toEqual(BRANCH_STATUS_PENDING);
+    });
+
+    it('should return pending state for pending result', async () => {
+      expect(await getBranchStatus('pending')).toEqual(BRANCH_STATUS_PENDING);
+    });
+
+    it('should return success state for success result', async () => {
+      expect(await getBranchStatus('success')).toEqual(BRANCH_STATUS_SUCCESS);
+    });
+
+    it('should return failed state for all other results', async () => {
+      expect(await getBranchStatus('invalid')).toEqual(BRANCH_STATUS_FAILED);
+    });
+
+    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<ght.CombinedCommitStatus>({
+          statuses: [],
+        })
+      );
+
+      expect(
+        await gitea.getBranchStatusCheck('some-branch', 'some-context')
+      ).toBeNull();
+    });
+
+    it('should return null with no matching results', async () => {
+      helper.getCombinedCommitStatus.mockResolvedValueOnce(
+        partial<ght.CombinedCommitStatus>({
+          statuses: [partial<ght.CommitStatus>({ context: 'other-context' })],
+        })
+      );
+
+      expect(
+        await gitea.getBranchStatusCheck('some-branch', 'some-context')
+      ).toBeNull();
+    });
+
+    it('should return status of matching result', async () => {
+      helper.getCombinedCommitStatus.mockResolvedValueOnce(
+        partial<ght.CombinedCommitStatus>({
+          statuses: [
+            partial<ght.CommitStatus>({
+              status: 'success',
+              context: 'some-context',
+            }),
+          ],
+        })
+      );
+
+      expect(
+        await gitea.getBranchStatusCheck('some-branch', 'some-context')
+      ).toEqual('success');
+    });
+  });
+
+  describe('setBranchStatus', () => {
+    it('should set default base branch', async () => {
+      await initFakeRepo();
+      await gitea.setBaseBranch();
+
+      expect(gsmSetBaseBranch).toHaveBeenCalledTimes(1);
+      expect(gsmSetBaseBranch).toHaveBeenCalledWith(mockRepo.default_branch);
+    });
+
+    it('should set custom base branch', async () => {
+      await initFakeRepo();
+      await gitea.setBaseBranch('devel');
+
+      expect(gsmSetBaseBranch).toHaveBeenCalledTimes(1);
+      expect(gsmSetBaseBranch).toHaveBeenCalledWith('devel');
+    });
+  });
+
+  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 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', async () => {
+      const mockPR = mockPRs[1];
+      helper.searchPRs.mockResolvedValueOnce(mockPRs);
+      helper.getBranch.mockResolvedValueOnce(
+        partial<ght.Branch>({
+          commit: {
+            id: mockCommitHash,
+            author: partial<ght.CommitUser>({ email: global.gitAuthor.email }),
+          },
+        })
+      );
+      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);
+      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);
+      helper.getBranch.mockResolvedValueOnce(
+        partial<ght.Branch>({
+          commit: {
+            id: mockCommitHash,
+            author: partial<ght.CommitUser>({
+              email: 'not-a-robot@renovatebot.com',
+            }),
+          },
+        })
+      );
+      await initFakeRepo();
+
+      const res = await gitea.getPr(mockPR.number);
+      expect(res).toHaveProperty('number', mockPR.number);
+      expect(res).toHaveProperty('isModified', true);
+    });
+  });
+
+  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.ref });
+      expect(res).toHaveProperty('branchName', mockPR.head.ref);
+    });
+
+    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.ref,
+        prTitle: mockPR.title,
+      });
+      expect(res).toHaveProperty('branchName', mockPR.head.ref);
+      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.ref,
+        state: mockPR.state,
+      });
+      expect(res).toHaveProperty('branchName', mockPR.head.ref);
+      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.ref,
+          state: `!${mockPR.state}` as ght.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.ref,
+        prTitle: mockPR.title,
+        state: mockPR.state,
+      });
+      expect(res).toHaveProperty('branchName', mockPR.head.ref);
+      expect(res).toHaveProperty('title', mockPR.title);
+      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: ght.PR = {
+      number: 42,
+      state: 'open',
+      head: {
+        ref: 'pr-branch',
+        sha: mockCommitHash,
+        repo: partial<ght.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();
+      await gitea.setBaseBranch('devel');
+      const res = await gitea.createPr({
+        branchName: mockNewPR.head.ref,
+        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.ref,
+        title: mockNewPR.title,
+        body: mockNewPR.body,
+        labels: [],
+      });
+    });
+
+    it('should use default branch if requested', async () => {
+      helper.createPR.mockResolvedValueOnce(mockNewPR);
+
+      await initFakeRepo();
+      await gitea.setBaseBranch('devel');
+      const res = await gitea.createPr({
+        branchName: mockNewPR.head.ref,
+        prTitle: mockNewPR.title,
+        prBody: mockNewPR.body,
+        useDefaultBranch: 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.ref,
+        title: mockNewPR.title,
+        body: mockNewPR.body,
+        labels: [],
+      });
+    });
+
+    it('should resolve and apply optional labels to pull request', async () => {
+      helper.createPR.mockResolvedValueOnce(mockNewPR);
+      helper.getRepoLabels.mockResolvedValueOnce(mockLabels);
+
+      await initFakeRepo();
+      await gitea.createPr({
+        branchName: mockNewPR.head.ref,
+        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.ref,
+        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({
+        branchName: mockNewPR.head.ref,
+        prTitle: mockNewPR.title,
+        prBody: mockNewPR.body,
+        useDefaultBranch: true,
+      });
+      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({
+        branchName: mockNewPR.head.ref,
+        prTitle: mockNewPR.title,
+        prBody: mockNewPR.body,
+        useDefaultBranch: true,
+      });
+
+      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({
+        branchName: mockNewPR.head.ref,
+        prTitle: 'new-title',
+        prBody: 'new-body',
+        useDefaultBranch: true,
+      });
+
+      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<ght.PR>({}));
+
+      await initFakeRepo();
+      await expect(
+        gitea.createPr({
+          branchName: mockNewPR.head.ref,
+          prTitle: mockNewPR.title,
+          prBody: mockNewPR.body,
+        })
+      ).rejects.toThrow();
+    });
+  });
+
+  describe('updatePr', () => {
+    it('should update pull request with title', async () => {
+      await initFakeRepo();
+      await gitea.updatePr(1, '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 () => {
+      await initFakeRepo();
+      await gitea.updatePr(1, 'New Title', 'New Body');
+
+      expect(helper.updatePR).toHaveBeenCalledTimes(1);
+      expect(helper.updatePR).toHaveBeenCalledWith(mockRepo.full_name, 1, {
+        title: 'New Title',
+        body: 'New Body',
+      });
+    });
+  });
+
+  describe('mergePr', () => {
+    it('should return true when merging succeeds', async () => {
+      await initFakeRepo();
+
+      expect(await gitea.mergePr(1, 'some-branch')).toEqual(true);
+      expect(helper.mergePR).toHaveBeenCalledTimes(1);
+      expect(helper.mergePR).toHaveBeenCalledWith(
+        mockRepo.full_name,
+        1,
+        'rebase'
+      );
+    });
+
+    it('should return false when merging fails', async () => {
+      helper.mergePR.mockRejectedValueOnce(new Error());
+      await initFakeRepo();
+
+      expect(await gitea.mergePr(1, 'some-branch')).toEqual(false);
+    });
+  });
+
+  describe('getPrFiles', () => {
+    it('should return empty list without passing a pull request', async () => {
+      await initFakeRepo();
+
+      expect(await gitea.getPrFiles(undefined)).toEqual([]);
+    });
+
+    it('should return modified files when passing a pull request', async () => {
+      const mockPR = mockPRs[0];
+      const mockDiff = `
+diff --git a/test b/test
+deleted file mode 100644
+index 60fffd1..0000000
+--- a/test
++++ /dev/null
+@@ -1 +0,0 @@
+-previously
+diff --git a/this is spaces b/this is spaces
+new file mode 100644
+index 0000000..2173594
+--- /dev/null
++++ b/this is spacey
+@@ -0,0 +1 @@
++nowadays
+`;
+
+      helper.getPR.mockResolvedValueOnce(mockPR);
+      api.get.mockResolvedValueOnce(
+        partial<GotResponse>({
+          body: mockDiff,
+        })
+      );
+      await initFakeRepo();
+
+      expect(await gitea.getPrFiles(mockPR.number)).toEqual([
+        'test',
+        'this is spaces',
+      ]);
+    });
+  });
+
+  describe('findIssue', () => {
+    it('should return existing open issue', async () => {
+      const mockIssue = mockIssues.find(i => i.title === 'open-issue');
+      helper.searchIssues.mockResolvedValueOnce(mockIssues);
+      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<ght.Issue>({ number: 42 })
+      );
+
+      await initFakeRepo();
+      const res = await gitea.ensureIssue(mockIssue);
+
+      expect(res).toEqual('created');
+      expect(helper.createIssue).toHaveBeenCalledTimes(1);
+      expect(helper.createIssue).toHaveBeenCalledWith(mockRepo.full_name, {
+        body: mockIssue.body,
+        title: mockIssue.title,
+      });
+    });
+
+    it('should not reopen closed issue by default', 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: false,
+      });
+
+      expect(res).toEqual('updated');
+      expect(helper.updateIssue).toHaveBeenCalledTimes(1);
+      expect(helper.updateIssue).toHaveBeenCalledWith(
+        mockRepo.full_name,
+        closedIssue.number,
+        {
+          body: closedIssue.body,
+          state: closedIssue.state,
+        }
+      );
+    });
+
+    it('should reopen 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: true,
+        once: false,
+      });
+
+      expect(res).toEqual('updated');
+      expect(helper.updateIssue).toHaveBeenCalledTimes(1);
+      expect(helper.updateIssue).toHaveBeenCalledWith(
+        mockRepo.full_name,
+        closedIssue.number,
+        {
+          body: closedIssue.body,
+          state: 'open',
+        }
+      );
+    });
+
+    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) {
+          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<ght.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 = mockLabels[0];
+      helper.getRepoLabels.mockResolvedValueOnce(mockLabels);
+      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(mockLabels);
+      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()).toEqual(false);
+    });
+  });
+
+  describe('ensureComment', () => {
+    it('should add comment with topic if not found', async () => {
+      helper.getComments.mockResolvedValueOnce(mockComments);
+      helper.createComment.mockResolvedValueOnce(
+        partial<ght.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).toEqual(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<ght.Comment>({ id: 42 })
+      );
+
+      await initFakeRepo();
+      const res = await gitea.ensureComment({
+        number: 1,
+        content: 'other-content',
+        topic: undefined,
+      });
+
+      expect(res).toEqual(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<ght.Comment>({ id: 42 })
+      );
+
+      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).toEqual(true);
+      expect(helper.createComment).not.toHaveBeenCalled();
+      expect(helper.updateComment).toHaveBeenCalledTimes(1);
+      expect(helper.updateComment).toHaveBeenCalledWith(
+        mockRepo.full_name,
+        1,
+        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).toEqual(true);
+      expect(helper.createComment).not.toHaveBeenCalled();
+      expect(helper.updateComment).not.toHaveBeenCalled();
+    });
+
+    it('should skip comments with topic "Renovate Ignore Notification"', async () => {
+      helper.getComments.mockResolvedValueOnce(mockComments);
+
+      await initFakeRepo();
+      const res = await gitea.ensureComment({
+        number: 1,
+        topic: 'Renovate Ignore Notification',
+        content: 'this-should-be-ignored-as-a-workaround',
+      });
+
+      expect(res).toEqual(false);
+      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).toEqual(false);
+      expect(logger.warn).toHaveBeenCalledTimes(1);
+    });
+  });
+
+  describe('ensureCommentRemoval', () => {
+    it('should remove existing comment', async () => {
+      helper.getComments.mockResolvedValueOnce(mockComments);
+      await initFakeRepo();
+      await gitea.ensureCommentRemoval(1, 'some-topic');
+
+      expect(helper.deleteComment).toHaveBeenCalledTimes(1);
+      expect(helper.deleteComment).toHaveBeenCalledWith(
+        mockRepo.full_name,
+        expect.any(Number)
+      );
+    });
+
+    it('should gracefully fail with warning', async () => {
+      helper.getComments.mockResolvedValueOnce(mockComments);
+      helper.deleteComment.mockRejectedValueOnce(new Error());
+      await initFakeRepo();
+      await gitea.ensureCommentRemoval(1, '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(1, '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.ref)).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('deleteBranch', () => {
+    it('should propagate call to storage class', async () => {
+      await initFakeRepo();
+      await gitea.deleteBranch('some-branch');
+
+      expect(gsmDeleteBranch).toHaveBeenCalledTimes(1);
+      expect(gsmDeleteBranch).toHaveBeenCalledWith('some-branch');
+    });
+
+    it('should not close pull request by default', async () => {
+      await initFakeRepo();
+      await gitea.deleteBranch('some-branch');
+
+      expect(helper.closePR).not.toHaveBeenCalled();
+    });
+
+    it('should close existing pull request if desired', async () => {
+      const mockPR = mockPRs[0];
+      helper.searchPRs.mockResolvedValueOnce(mockPRs);
+      await initFakeRepo();
+      await gitea.deleteBranch(mockPR.head.ref, true);
+
+      expect(helper.closePR).toHaveBeenCalledTimes(1);
+      expect(helper.closePR).toHaveBeenCalledWith(
+        mockRepo.full_name,
+        mockPR.number
+      );
+      expect(gsmDeleteBranch).toHaveBeenCalledTimes(1);
+      expect(gsmDeleteBranch).toHaveBeenCalledWith(mockPR.head.ref);
+    });
+
+    it('should skip closing pull request if missing', async () => {
+      helper.searchPRs.mockResolvedValueOnce(mockPRs);
+      await initFakeRepo();
+      await gitea.deleteBranch('missing', true);
+
+      expect(helper.closePR).not.toHaveBeenCalled();
+      expect(gsmDeleteBranch).toHaveBeenCalledTimes(1);
+      expect(gsmDeleteBranch).toHaveBeenCalledWith('missing');
+    });
+  });
+
+  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 do nothing - unsupported by platform', async () => {
+      const mockPR = mockPRs[0];
+      await gitea.addReviewers(mockPR.number, ['me', 'you']);
+    });
+  });
+
+  describe('commitFilesToBranch', () => {
+    it('should propagate call to storage class with default parent branch', async () => {
+      const commitConfig: CommitFilesConfig = {
+        branchName: 'some-branch',
+        files: [partial<File>({})],
+        message: 'some-message',
+      };
+
+      await initFakeRepo();
+      await gitea.commitFilesToBranch(commitConfig);
+
+      expect(gsmCommitFilesToBranch).toHaveBeenCalledTimes(1);
+      expect(gsmCommitFilesToBranch).toHaveBeenCalledWith({
+        ...commitConfig,
+        parentBranch: mockRepo.default_branch,
+      });
+    });
+
+    it('should propagate call to storage class with custom parent branch', async () => {
+      const commitConfig: CommitFilesConfig = {
+        branchName: 'some-branch',
+        files: [partial<File>({})],
+        message: 'some-message',
+        parentBranch: 'some-parent-branch',
+      };
+
+      await initFakeRepo();
+      await gitea.commitFilesToBranch(commitConfig);
+
+      expect(gsmCommitFilesToBranch).toHaveBeenCalledTimes(1);
+      expect(gsmCommitFilesToBranch).toHaveBeenCalledWith(commitConfig);
+    });
+  });
+
+  describe('getPrBody', () => {
+    it('should truncate body to 1000000 characters', () => {
+      const excessiveBody = '*'.repeat(1000001);
+
+      expect(gitea.getPrBody(excessiveBody)).toHaveLength(1000000);
+    });
+  });
+
+  describe('isBranchStale', () => {
+    it('propagates call to storage class', async () => {
+      await initFakeRepo();
+      await gitea.isBranchStale('some-branch');
+
+      expect(gsmIsBranchStale).toHaveBeenCalledTimes(1);
+      expect(gsmIsBranchStale).toHaveBeenCalledWith('some-branch');
+    });
+  });
+
+  describe('setBranchPrefix', () => {
+    it('should propagate call to storage class', async () => {
+      await initFakeRepo();
+      await gitea.setBranchPrefix('some-branch');
+
+      expect(gsmSetBranchPrefix).toHaveBeenCalledTimes(1);
+      expect(gsmSetBranchPrefix).toHaveBeenCalledWith('some-branch');
+    });
+  });
+
+  describe('branchExists', () => {
+    it('should propagate call to storage class', async () => {
+      await initFakeRepo();
+      await gitea.branchExists('some-branch');
+
+      expect(gsmBranchExists).toHaveBeenCalledTimes(1);
+      expect(gsmBranchExists).toHaveBeenCalledWith('some-branch');
+    });
+  });
+
+  describe('mergeBranch', () => {
+    it('should propagate call to storage class', async () => {
+      await initFakeRepo();
+      await gitea.mergeBranch('some-branch');
+
+      expect(gsmMergeBranch).toHaveBeenCalledTimes(1);
+      expect(gsmMergeBranch).toHaveBeenCalledWith('some-branch');
+    });
+  });
+
+  describe('getBranchLastCommitTime', () => {
+    it('should propagate call to storage class', async () => {
+      await initFakeRepo();
+      await gitea.getBranchLastCommitTime('some-branch');
+
+      expect(gsmGetBranchLastCommitTime).toHaveBeenCalledTimes(1);
+      expect(gsmGetBranchLastCommitTime).toHaveBeenCalledWith('some-branch');
+    });
+  });
+
+  describe('getFile', () => {
+    it('should propagate call to storage class', async () => {
+      await initFakeRepo();
+      await gitea.getFile('some-file', 'some-branch');
+
+      expect(gsmGetFile).toHaveBeenCalledTimes(1);
+      expect(gsmGetFile).toHaveBeenCalledWith('some-file', 'some-branch');
+    });
+  });
+
+  describe('getRepoStatus', () => {
+    it('should propagate call to storage class', async () => {
+      await initFakeRepo();
+      await gitea.getRepoStatus();
+
+      expect(gsmGetRepoStatus).toHaveBeenCalledTimes(1);
+    });
+  });
+
+  describe('getFileList', () => {
+    it('propagates call to storage class', async () => {
+      await initFakeRepo();
+      await gitea.getFileList();
+
+      expect(gsmGetFileList).toHaveBeenCalledTimes(1);
+    });
+  });
+
+  describe('getAllRenovateBranches', () => {
+    it('should propagate call to storage class', async () => {
+      await initFakeRepo();
+      await gitea.getAllRenovateBranches('some-prefix');
+
+      expect(gsmGetAllRenovateBranches).toHaveBeenCalledTimes(1);
+      expect(gsmGetAllRenovateBranches).toHaveBeenCalledWith('some-prefix');
+    });
+  });
+
+  describe('getCommitMessages', () => {
+    it('should propagate call to storage class', async () => {
+      await initFakeRepo();
+      await gitea.getCommitMessages();
+
+      expect(gsmGetCommitMessages).toHaveBeenCalledTimes(1);
+    });
+  });
+
+  describe('getVulnerabilityAlerts', () => {
+    it('should return an empty list - unsupported by platform', async () => {
+      expect(await gitea.getVulnerabilityAlerts()).toEqual([]);
+    });
+  });
+});
diff --git a/test/platform/index.spec.ts b/test/platform/index.spec.ts
index 1885ce82b8..bd96a4bb37 100644
--- a/test/platform/index.spec.ts
+++ b/test/platform/index.spec.ts
@@ -1,5 +1,6 @@
 import * as github from '../../lib/platform/github';
 import * as gitlab from '../../lib/platform/gitlab';
+import * as gitea from '../../lib/platform/gitea';
 import * as azure from '../../lib/platform/azure';
 import * as bitbucket from '../../lib/platform/bitbucket';
 import * as bitbucketServer from '../../lib/platform/bitbucket-server';
@@ -50,6 +51,11 @@ describe('platform', () => {
     expect(gitlabMethods).toMatchSnapshot();
   });
 
+  it('has a list of supported methods for gitea', () => {
+    const giteaMethods = Object.keys(gitea).sort();
+    expect(giteaMethods).toMatchSnapshot();
+  });
+
   it('has a list of supported methods for azure', () => {
     const azureMethods = Object.keys(azure).sort();
     expect(azureMethods).toMatchSnapshot();
@@ -61,6 +67,12 @@ describe('platform', () => {
     expect(githubMethods).toMatchObject(gitlabMethods);
   });
 
+  it('has same API for github and gitea', () => {
+    const githubMethods = Object.keys(github).sort();
+    const giteaMethods = Object.keys(gitea).sort();
+    expect(githubMethods).toMatchObject(giteaMethods);
+  });
+
   it('has same API for github and azure', () => {
     const githubMethods = Object.keys(github).sort();
     const azureMethods = Object.keys(azure).sort();
-- 
GitLab