diff --git a/reviewboard/dependencies.py b/reviewboard/dependencies.py
index 95a46038a427331b0ea50007304bfc04f20c6868..bcd09b2ddb79d3863a568f227504c9a29fd188b2 100644
--- a/reviewboard/dependencies.py
+++ b/reviewboard/dependencies.py
@@ -76,6 +76,7 @@
     'mimeparse': '~=0.1.3',
     'packaging': '>=23.1',
     'paramiko': '~=3.4.1',
+    'pydantic': '~=2.5',
     'pydiffx': '~=1.1',
     'Pygments': '~=2.14.0',
 
diff --git a/reviewboard/hostingsvcs/base/registry.py b/reviewboard/hostingsvcs/base/registry.py
index f4e38b55123607549db30cac30b429cedcac14e8..9e311208db976bb955d34100f1230f1f4a7aa183 100644
--- a/reviewboard/hostingsvcs/base/registry.py
+++ b/reviewboard/hostingsvcs/base/registry.py
@@ -94,6 +94,7 @@
                 ('codebasehq', 'CodebaseHQ'),
                 ('fedorahosted', 'FedoraHosted'),
                 ('fogbugz', 'FogBugz'),
+                ('forgejo', 'Forgejo'),
                 ('gerrit', 'Gerrit'),
                 ('github', 'GitHub'),
                 ('gitlab', 'GitLab'),
diff --git a/reviewboard/hostingsvcs/forgejo/__init__.py b/reviewboard/hostingsvcs/forgejo/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..c7ef55325435717543a0325846de0f35864b27e4

--- /dev/null
+++ b/reviewboard/hostingsvcs/forgejo/__init__.py
@@ -0,0 +1,16 @@
+"""Hosting service for Forgejo.
+
+Version Added:
+    7.1
+"""
+
+from __future__ import annotations
+
+from reviewboard.hostingsvcs.forgejo.service import Forgejo
+
+
+__all__ = [
+    'Forgejo',
+]
+
+__autodoc_excludes__ = __all__
diff --git a/reviewboard/hostingsvcs/forgejo/api.py b/reviewboard/hostingsvcs/forgejo/api.py
new file mode 100644
index 0000000000000000000000000000000000000000..109373c791ee21e8b38cc125843d64468b5f8c11

--- /dev/null
+++ b/reviewboard/hostingsvcs/forgejo/api.py
@@ -0,0 +1,181 @@
+"""API interfaces for Forgejo.
+
+The names of the classes in this module reflect the names used in the Forgejo
+swagger UI. The data definitions here do not comprehensively include all
+fields, only those that we need for our use.
+
+Version Added:
+    7.1
+"""
+
+from __future__ import annotations
+
+from pydantic import BaseModel
+
+
+class APIError(BaseModel):
+    """API error for Forgejo.
+
+    Version Added:
+        7.1
+    """
+
+    message: str
+    url: str
+
+
+class AccessToken(BaseModel):
+    """API response for a Forgejo access token.
+
+    Version Added:
+        7.1
+    """
+
+    id: int
+    name: str
+    scopes: list[str]
+    sha1: str
+    token_last_eight: str
+
+
+class GitBlobResponse(BaseModel):
+    """API response for a Git blob.
+
+    Version Added:
+        7.1
+    """
+
+    content: str
+    encoding: str
+    sha: str
+    size: int
+    url: str
+
+
+class CommitMeta(BaseModel):
+    """Commit metadata.
+
+    Version Added:
+        7.1
+    """
+
+    created: str
+    sha: str
+    url: str
+
+
+class CommitUser(BaseModel):
+    """A user for a commit.
+
+    Version Added:
+        7.1
+    """
+
+    date: str
+    email: str
+    name: str
+
+
+class Commit(BaseModel):
+    """API response for a Git commit.
+
+    Version Added:
+        7.1
+    """
+
+    commit: RepoCommit
+    created: str
+    parents: list[CommitMeta]
+    sha: str
+
+
+class GitEntry(BaseModel):
+    """An entry in a Git tree.
+
+    Version Added:
+        7.1
+    """
+
+    path: str
+    sha: str
+    size: int
+    type: str
+
+
+class GitObject(BaseModel):
+    """API response for a Git object.
+
+    Version Added:
+        7.1
+    """
+
+    sha: str
+    type: str
+    url: str
+
+
+class GitReference(BaseModel):
+    """API data for a Git reference.
+
+    Version Added:
+        7.1
+    """
+
+    object: GitObject
+    ref: str
+    url: str
+
+
+class GitTreeResponse(BaseModel):
+    """API data for a Git tree.
+
+    Version Added:
+        7.1
+    """
+
+    page: int
+    sha: str
+    total_count: int
+    tree: list[GitEntry] | None
+    truncated: bool
+
+
+class Issue(BaseModel):
+    """API data for an issue.
+
+    Version Added:
+        7.1
+    """
+
+    body: str
+    id: int
+    state: str
+    title: str
+
+
+class Repository(BaseModel):
+    """API data for a repository.
+
+    Version Added:
+        7.1
+    """
+
+    clone_url: str
+    default_branch: str
+    description: str
+    id: int
+    name: str
+    private: bool
+
+
+class RepoCommit(BaseModel):
+    """Data about a commit.
+
+    Version Added:
+        7.1
+    """
+
+    author: CommitUser
+    committer: CommitUser
+    message: str
+    tree: CommitMeta
diff --git a/reviewboard/hostingsvcs/forgejo/client.py b/reviewboard/hostingsvcs/forgejo/client.py
new file mode 100644
index 0000000000000000000000000000000000000000..e758e7283f1f24731e24f44ab15a85ecbde9b250

--- /dev/null
+++ b/reviewboard/hostingsvcs/forgejo/client.py
@@ -0,0 +1,856 @@
+"""Client for Forgejo.
+
+Version Added:
+    7.1
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import re
+from base64 import b64decode
+from typing import TypeVar, TYPE_CHECKING, cast
+from urllib.error import HTTPError
+from urllib.parse import urlencode
+
+from django.utils.translation import gettext as _
+from pydantic import BaseModel, TypeAdapter, ValidationError
+
+from reviewboard.hostingsvcs.base.client import HostingServiceClient
+from reviewboard.hostingsvcs.errors import (
+    AuthorizationError,
+    HostingServiceError,
+    HostingServiceAPIError,
+    TwoFactorAuthCodeRequiredError,
+)
+from reviewboard.hostingsvcs.forgejo import api
+from reviewboard.hostingsvcs.forgejo.errors import APITokenNameInUseError
+from reviewboard.scmtools.core import Branch, Commit
+from reviewboard.scmtools.crypto_utils import decrypt_password
+from reviewboard.scmtools.errors import (
+    FileNotFoundError,
+    RepositoryNotFoundError,
+)
+
+if TYPE_CHECKING:
+    from collections.abc import Sequence
+
+    from reviewboard.hostingsvcs.base.hosting_service import \
+        HostingServiceCredentials
+    from reviewboard.hostingsvcs.base.bug_tracker import BugInfo
+    from reviewboard.hostingsvcs.forgejo.service import Forgejo
+    from reviewboard.hostingsvcs.models import HostingServiceAccount
+    from reviewboard.scmtools.models import Repository
+
+
+logger = logging.getLogger(__name__)
+
+
+_T = TypeVar('_T')
+_TModel = TypeVar('_TModel', bound=BaseModel)
+
+
+class ForgejoClient(HostingServiceClient):
+    """Hosting service client for Forgejo.
+
+    Version Added:
+        7.1
+    """
+
+    #: Regex for index lines in the diff.
+    _index_line_re: re.Pattern[bytes]
+
+    #: Regex for SHAs for deleted/added files.
+    _missing_file_sha_re: re.Pattern[str]
+
+    def __init__(
+        self,
+        hosting_service: Forgejo,
+    ) -> None:
+        """Initialie the client.
+
+        Args:
+            hosting_service (Forgejo):
+                The hosting service instance.
+        """
+        super().__init__(hosting_service)
+
+        self.account = hosting_service.account
+        self._index_line_re = re.compile(
+            br'^index (?P<a>[0-9a-f]+)..(?P<b>[0-9a-f]+)((?P<rest>\s.*)?)$')
+        self._missing_file_sha_re = re.compile(r'^0+$')
+
+    def get_http_credentials(
+        self,
+        account: HostingServiceAccount,
+        username: (str | None) = None,
+        password: (str | None) = None,
+        **kwargs,
+    ) -> HostingServiceCredentials:
+        """Return credentials used to authenticate with Forgejo.
+
+        Args:
+            account (reviewboard.hostingsvcs.models.HostingServiceAccount):
+                The stored authentication data for the service.
+
+            username (str, optional):
+                An explicit username passed by the caller. This is not used.
+
+            password (str, optional):
+                An explicit password passed by the caller. This is not used.
+
+            **kwargs (dict, unused):
+                Additional keyword arguments passed in when making the HTTP
+                request.
+
+        Returns:
+            dict:
+            A dictionary of credentials for the request.
+        """
+        if token := account.data.get('api_token'):
+            return {
+                'header': {
+                    'Authorization': f'token {decrypt_password(token)}',
+                },
+            }
+
+        return {}
+
+    def check_repository(
+        self,
+        *,
+        hosting_url: str,
+        repository_owner: str,
+        repository_name: str,
+    ) -> None:
+        """Check that a repository is valid and accessible.
+
+        Args:
+            hosting_url (str):
+                The URL of the Forgejo server.
+
+            repository_owner (str):
+                The repository owner.
+
+            repository_name (str):
+                The repository name.
+
+        Raises:
+            reviewboard.scmtools.errors.RepositoryNotFoundError:
+                A repository was not found with the given parameters.
+        """
+        url = self._get_api_repo_root(
+            hosting_url=hosting_url,
+            repository_owner=repository_owner,
+            repository_name=repository_name)
+
+        try:
+            # We don't actually use any fields from this (right now), but we
+            # want to attempt to deserialize just to verify that we have a
+            # valid repository response.
+            self._api_get(
+                url=url,
+                result_type=api.Repository)
+        except HostingServiceAPIError as e:
+            error = cast(api.APIError, e.rsp)
+
+            if error.message == 'GetUserByName':
+                raise RepositoryNotFoundError(
+                    (
+                        _('A user with the name {repository_owner} was not '
+                          'found or is not accessible.')
+                        .format(repository_owner=repository_owner)
+                    ),
+                    form_field_id='repository_owner')
+            elif e.http_code == 404:
+                raise RepositoryNotFoundError(
+                    (
+                        _('A repository with the name {repository_name} was '
+                          'not found for the owner {repository_owner}')
+                        .format(repository_owner=repository_owner,
+                                repository_name=repository_name)
+                    ),
+                    form_field_id='repository_name')
+            else:
+                logger.warning(
+                    'Forgejo: Unexpected error when checking repository with '
+                    'API URL %s: %s',
+                    url, str(e))
+
+                raise RepositoryNotFoundError()
+        except HostingServiceError as e:
+            logger.warning(
+                'Forgejo: Unexpected error when checking repository with '
+                'API URL %s: %s',
+                url, str(e))
+
+            raise RepositoryNotFoundError()
+
+    def create_api_token(
+        self,
+        *,
+        token_name: str,
+        hosting_url: str,
+        username: str,
+        password: str,
+        two_factor_auth_code: (str | None) = None,
+    ) -> str:
+        """Create an API token for the given user.
+
+        Args:
+            token_name (str):
+                The name to use for the token.
+
+            hosting_url (str):
+                The URL of the Forgejo server.
+
+            username (str):
+                The username for the account.
+
+            password (str):
+                The password for the account.
+
+            two_factor_auth_code (str, optional):
+                The two-factor authentication code provided by the user.
+
+        Returns:
+            str:
+
+        Raises:
+            reviewboard.certs.errors.CertificateVerificationError:
+                The certificate for the host is invalid or untrusted.
+
+            reviewboard.hostingsvcs.errors.AuthorizationError:
+                The credentials provided were not valid.
+
+            reviewboard.hostingsvcs.errors.TwoFactorAuthCodeRequiredError:
+                A two-factor authentication code is required to authorize
+                this account. The request must be retried with the same
+                credentials and with the ``two_factor_auth_code`` parameter
+                provided.
+
+            reviewboard.hostingsvcs.errors.HostingServiceError:
+                A non-authentication error occurred.
+
+            reviewboard.hostingsvcs.forgejo.errors.APITokenNameInUseError:
+                The API token name is was already used.
+        """
+        api_root = self._get_api_root(hosting_url)
+        url = f'{api_root}users/{username}/tokens'
+
+        try:
+            body = json.dumps({
+                'name': token_name,
+                'scopes': [
+                    'read:issue',
+                    'read:organization',
+                    'read:repository',
+                    'read:user',
+                ],
+            }).encode()
+
+            headers = {
+                'Accept': 'application/json',
+            }
+
+            if two_factor_auth_code:
+                headers['X-Forgejo-OTP'] = two_factor_auth_code
+
+            rsp = self.http_post(
+                url,
+                body=body,
+                content_type='Application/json',
+                headers=headers,
+                username=username,
+                password=password)
+
+            logger.info('Forgejo: Created API token for user %s on server %s',
+                        username, hosting_url)
+            access_token = api.AccessToken.model_validate_json(rsp.data)
+
+            return access_token.sha1
+        except HTTPError as e:
+            data = e.read()
+
+            logger.error('Forgejo: HTTP error (code=%s) creating API token '
+                         'with POST %s: %s ',
+                         e.code, url, data.decode())
+
+            try:
+                error = api.APIError.model_validate_json(data)
+            except ValidationError:
+                error = data.decode()
+
+                logger.error('Forgejo: Unexpected error (HTTP %d) when '
+                             'creating API token for user %s on server %s: %s',
+                             e.code, username, hosting_url, error)
+
+                raise HostingServiceError(
+                    _(
+                        'Unknown error while creating Forgejo API token: '
+                        '{e} (HTTP code {code}'
+                    )
+                    .format(e=data.decode(), code=e.code))
+
+            if error.message == 'access token name has been used already':
+                logger.warning(
+                    'Forgejo: Creating API token (name="%s") for user %s on '
+                    'server %s failed due to duplicate name.',
+                    token_name, username, hosting_url)
+
+                raise APITokenNameInUseError()
+            elif error.message == 'invalid provided OTP':
+                logger.info('Forgejo: invalid OTP when creating API token '
+                            'for user %s on server %s',
+                            username, hosting_url)
+
+                raise TwoFactorAuthCodeRequiredError(
+                    _('Enter your two-factor authentication code'))
+            elif e.code == 401:
+                logger.warning('Forgejo: authentication failure when creating '
+                               'API token for user %s on server %s',
+                               username, hosting_url)
+
+                raise AuthorizationError(
+                    _('The login or password is incorrect.'))
+            else:
+                logger.error('Forgejo: Unexpected error when creating API '
+                             'token for user %s on server %s: %s',
+                             username, hosting_url, error.message)
+
+                raise HostingServiceError(error.message)
+
+    def get_blob(
+        self,
+        *,
+        hosting_url: str,
+        repository: Repository,
+        path: str,
+        sha: str,
+    ) -> bytes:
+        """Retrieve a git blob.
+
+        Args:
+            hosting_url (str):
+                The URL of the Forgejo server.
+
+            repository (reviewboard.scmtools.models.Repsitory):
+                The repository.
+
+            path (str):
+                The path of the file.
+
+            sha (str):
+                The hash of the blob to retrieve.
+
+        Returns:
+            bytes:
+            The contents of the file.
+
+        Raises:
+            reviewboard.hostingsvcs.errors.HostingServiceError:
+                An error occurred when fetching the file.
+
+            reviewboard.scmtools.errors.FileNotFoundError:
+                The blob with the given SHA was not found.
+        """
+        repo_root = self._get_api_repo_root(hosting_url, repository)
+
+        try:
+            blob = self._api_get(
+                url=f'{repo_root}/git/blobs/{sha}',
+                result_type=api.GitBlobResponse,
+                repository=repository)
+
+            if blob.encoding != 'base64':
+                logger.error('Forgejo: Failed to get blob "%s" for repository '
+                             'pk=%d: encoding was "%s" but only base64 is '
+                             'supported.',
+                             sha, repository.pk, blob.encoding)
+
+                raise HostingServiceError(
+                    _('Forgejo returned data with an unknown encoding. '
+                      'See the Review Board log file for details.'))
+
+            return b64decode(blob.content)
+        except HostingServiceAPIError as e:
+            error = cast(api.APIError, e.rsp)
+
+            # If a blob with the given SHA cannot be found, this endpoint
+            # returns HTTP 400 with this message. We only get HTTP 404 if
+            # the owner/repository names in the URL are incorrect.
+            if error.message.startswith('object does not exist'):
+                raise FileNotFoundError(path, revision=sha)
+            else:
+                raise e
+        except Exception as e:
+            raise HostingServiceError(str(e))
+
+    def get_branches(
+        self,
+        *,
+        hosting_url: str,
+        repository: Repository,
+    ) -> Sequence[Branch]:
+        """Return a list of all branches in the repositories.
+
+        This should be implemented by subclasses, and is expected to return a
+        list of Branch objects. One (and only one) of those objects should have
+        the "default" field set to True.
+
+        Args:
+            hosting_url (str):
+                The URL of the Forgejo server.
+
+            repository (reviewboard.scmtools.models.Repository):
+                The repository for which branches should be returned.
+
+        Returns:
+            list of reviewboard.scmtools.core.Branch:
+            The branches.
+
+        Raises:
+            reviewboard.hostingsvcs.errors.HostingServiceError:
+                There was an error fetching branches.
+        """
+        repo_root = self._get_api_repo_root(hosting_url, repository)
+
+        repo_info = self._api_get(url=repo_root, result_type=api.Repository)
+
+        refs = self._api_get(
+            url=f'{repo_root}/git/refs',
+            result_type=TypeAdapter(list[api.GitReference]),
+            repository=repository)
+
+        results: list[Branch] = []
+
+        default_ref = None
+        main_ref = None
+        master_ref = None
+
+        for i, ref in enumerate(refs):
+            name = ref.ref.removeprefix('refs/heads/')
+            results.append(Branch(id=name, commit=ref.object.sha))
+
+            if name == repo_info.default_branch:
+                default_ref = i
+            if name == 'main':
+                main_ref = i
+            elif name == 'master':
+                master_ref = i
+
+        # Prefer the configured default, then "main", then "master".
+        if default_ref is not None:
+            results[default_ref].default = True
+        elif main_ref is not None:
+            results[main_ref].default = True
+        elif master_ref is not None:
+            results[master_ref].default = True
+        elif len(results) > 0:
+            results[0].default = True
+
+        return results
+
+    def get_commits(
+        self,
+        hosting_url: str,
+        repository: Repository,
+        branch: (str | None) = None,
+        start: (str | None) = None,
+    ) -> Sequence[Commit]:
+        """Return a list of commits backward in history from a given point.
+
+        Args:
+            hosting_url (str):
+                The URL of the Forgejo server.
+
+            repository (reviewboard.scmtools.models.Repository):
+                The repository to retrieve commits from.
+
+            branch (str, optional):
+                The branch to retrieve from.
+
+                If this is not provided, the default branch will be used.
+
+            start (str, optional):
+                An optional starting revision.
+
+                If this is not provided, the most recent commits will be
+                returned.
+
+        Returns:
+            list of reviewboard.scmtools.core.Commit:
+            The retrieved commits.
+
+        Raises:
+            reviewboard.hostingsvcs.errors.HostingServiceError:
+                There was an error fetching commits.
+        """
+        repo_root = self._get_api_repo_root(hosting_url, repository)
+
+        sha = start or branch
+        assert isinstance(sha, str)
+
+        commits = self._api_get(
+            url=f'{repo_root}/commits',
+            result_type=TypeAdapter(list[api.Commit]),
+            params={
+                'sha': sha,
+                'stat': '0',
+                'verification': '0',
+                'files': '0',
+                'limit': '30',
+            },
+            repository=repository)
+
+        results: list[Commit] = []
+
+        for commit in commits:
+            if commit.parents:
+                parent = commit.parents[0].sha
+            else:
+                parent = ''
+
+            results.append(Commit(
+                author_name=commit.commit.author.name,
+                id=commit.sha,
+                date=commit.created,
+                message=commit.commit.message,
+                parent=parent,
+            ))
+
+        return results
+
+    def get_change(
+        self,
+        hosting_url: str,
+        repository: Repository,
+        revision: str,
+    ) -> Commit:
+        """Return an individual change.
+
+        Args:
+            hosting_url (str):
+                The URL of the Forgejo server.
+
+            repository (reviewboard.scmtools.models.Repository):
+                The repository to get the change from.
+
+            revision (str):
+                The revision to retrieve.
+
+        Returns:
+            reviewboard.scmtools.core.Commit:
+            The change.
+
+        Raises:
+            reviewboard.hostingsvcs.errors.HostingServiceError:
+                There was an error fetching the commit.
+        """
+        repo_root = self._get_api_repo_root(hosting_url, repository)
+
+        commit = self._api_get(
+            url=f'{repo_root}/git/commits/{revision}',
+            result_type=api.Commit,
+            repository=repository)
+
+        if commit.parents:
+            parent = commit.parents[0].sha
+        else:
+            parent = ''
+
+        diff_url = f'{repo_root}/git/commits/{revision}.diff'
+
+        try:
+            diff = self.http_get(diff_url).data.splitlines()
+        except HTTPError as e:
+            logger.error('Forgejo: HTTP error fetching diff at %s '
+                         '(repository %s): %s',
+                         diff_url, repository, e)
+
+            raise HostingServiceError('TODO error')
+
+        all_shas = (
+            self._get_blob_shas_in_tree(repo_root, commit.sha, repository) |
+            self._get_blob_shas_in_tree(repo_root, parent, repository))
+
+        diff_lines = list[bytes]()
+
+        def expand_sha(
+            partial_sha: str,
+        ) -> str:
+            if self._missing_file_sha_re.match(partial_sha):
+                return '0' * 40
+
+            for full_sha in all_shas:
+                if full_sha.startswith(partial_sha):
+                    return full_sha
+
+            logger.warning('Forgejo: Unable to find full SHA for blob %s '
+                           '(repository %s)',
+                           partial_sha, repository)
+            return partial_sha
+
+        for line in diff:
+            m = self._index_line_re.match(line)
+
+            if m:
+                sha1 = expand_sha(m.group('a').decode())
+                sha2 = expand_sha(m.group('b').decode())
+                rest = m.group('rest') or b''
+
+                new_line = (b'index %s..%s%s'
+                            % (sha1.encode(), sha2.encode(), rest))
+
+                diff_lines.append(new_line)
+            else:
+                new_line = line
+                diff_lines.append(line)
+
+        return Commit(
+            author_name=commit.commit.author.name,
+            id=commit.sha,
+            date=commit.created,
+            message=commit.commit.message,
+            parent=parent,
+            diff=b'\n'.join(diff_lines),
+        )
+
+    def get_bug_info(
+        self,
+        hosting_url: str,
+        repository: Repository,
+        bug_id: str,
+    ) -> BugInfo:
+        """Return the information for the specified bug.
+
+        Args:
+            hosting_url (str):
+                The URL of the Forgejo server.
+
+            repository (reviewboard.scmtools.models.Repository):
+                The repository object.
+
+            bug_id (str):
+                The ID of the bug to fetch.
+
+        Returns:
+            reviewboard.hostingsvcs.bugtracker.BugInfo:
+            Information about the bug.
+        """
+        repo_root = self._get_api_repo_root(hosting_url, repository)
+
+        result: BugInfo = {
+            'summary': '',
+            'description': '',
+            'status': '',
+        }
+
+        try:
+            issue = self._api_get(
+                url=f'{repo_root}/issues/{bug_id}',
+                result_type=api.Issue,
+                repository=repository)
+
+            result['summary'] = issue.title
+            result['description'] = issue.body
+            result['description_text_format'] = 'markdown'
+            result['status'] = issue.state
+        except HostingServiceError:
+            # Errors will already be logged, and the failure mode for this is
+            # to return the result with empty strings.
+            pass
+
+        return result
+
+    def _api_get(
+        self,
+        *,
+        url: str,
+        result_type: type[_TModel] | TypeAdapter[_T],
+        params: (dict[str, str] | None) = None,
+        repository: (Repository | None) = None,
+    ) -> _TModel | _T:
+        """Perform a GET request to the API.
+
+        Args:
+            url (str):
+                The URL of the API endpoint.
+
+            result_type (pydantic.BaseModel or pydantic.TypeAdapter):
+                The pydantic model or adapter to use for deserialization.
+
+            params (dict):
+                Parameters to include in the URL.
+
+            repository (reviewboard.scmtools.models.Repository, optional):
+                The repository object, if available.
+
+        Returns:
+            object:
+            The deserialized data.
+
+        Raises:
+            reviewboard.hostingsvcs.base.errors.HostingServiceError:
+                An error occurred while making the request.
+
+            reviewboard.hostingsvcs.base.errors.HostingServiceAPIError:
+                An error occurred while making the request, with a parsed error
+                structure.
+        """
+        if params:
+            query = urlencode(params)
+            url = f'{url}?{query}'
+
+        logger.debug('Forgejo: Making GET request to %s',
+                     url)
+
+        try:
+            rsp = self.http_get(url)
+
+            if isinstance(result_type, TypeAdapter):
+                return result_type.validate_json(rsp.data)
+            else:
+                return result_type.model_validate_json(rsp.data)
+        except ValidationError as e:
+            logger.error('Forgejo: Data validation failed for API GET %s '
+                         '(repository=%s): %s',
+                         url, repository, e)
+
+            raise HostingServiceError(
+                _('Unexpected response from Forgejo server.'))
+        except HTTPError as e:
+            logger.error('Forgejo: HTTP error in API GET %s for repository '
+                         '%s: %s',
+                         url, repository, e)
+
+            data = e.read()
+
+            try:
+                error = api.APIError.model_validate_json(data)
+
+                raise HostingServiceAPIError(
+                    _('API error from Forgejo: {e}').format(e=error.message),
+                    http_code=e.code,
+                    rsp=error,
+                )
+            except ValidationError as pydantic_err:
+                logger.error('Forgejo: Unable to parse error response for '
+                             'API GET %s (repository=%s): %s',
+                             url, repository, pydantic_err)
+
+                raise HostingServiceError(
+                    _('Unknown response from Forgejo: {rsp}').format(
+                        rsp=data.decode()),
+                    http_code=e.code)
+
+    def _get_api_root(
+        self,
+        hosting_url: str,
+    ) -> str:
+        """Return the root URL for the API.
+
+        Args:
+            hosting_url (str):
+                The URL of the Forgejo server.
+
+        Returns:
+            str:
+            The base URL for the API.
+        """
+        if not hosting_url.endswith('/'):
+            hosting_url += '/'
+
+        return f'{hosting_url}api/v1/'
+
+    def _get_api_repo_root(
+        self,
+        hosting_url: str,
+        repository: (Repository | None) = None,
+        repository_owner: (str | None) = None,
+        repository_name: (str | None) = None,
+    ) -> str:
+        """Get the API root for a repository in the API.
+
+        Args:
+            hosting_url (str):
+                The URL of the Forgejo server.
+
+            repository (reviewboard.scmtools.models.Repository, optional):
+                The repository object.
+
+            repository_owner (str, optional):
+                The repository owner.
+
+            repository_name (str, optional):
+                The repository name.
+
+        Returns:
+            str:
+            The base URL for API resources for the repository.
+        """
+        if repository:
+            repository_owner = repository.extra_data['repository_owner']
+            repository_name = repository.extra_data['repository_name']
+        elif not repository_owner or not repository_name:
+            raise ValueError('Either a repository or an owner/name must be '
+                             'provided')
+
+        api_root = self._get_api_root(hosting_url)
+
+        return f'{api_root}repos/{repository_owner}/{repository_name}'
+
+    def _get_blob_shas_in_tree(
+        self,
+        repo_root: str,
+        commit: str,
+        repository: Repository,
+    ) -> set[str]:
+        """Return all the SHAs in the tree for a given commit.
+
+        Args:
+            repo_root (str):
+                The URL root for the repository.
+
+            commit (str):
+                The SHA of the commit to fetch the tree for.
+
+            repository (reviewboard.scmtools.models.Repository):
+                The repository.
+
+        Returns:
+            set:
+            A set of all the blob SHAs in the tree.
+
+        Raises:
+            reviewboard.hostingsvcs.errors.HostingServiceError:
+                There was an error fetching the tree.
+        """
+        url = f'{repo_root}/git/trees/{commit}'
+        result = set[str]()
+
+        page = 1
+
+        while 1:
+            tree_page = self._api_get(
+                url=url,
+                result_type=api.GitTreeResponse,
+                params={
+                    'page': str(page),
+                    'recursive': 'true',
+                },
+                repository=repository)
+
+            if tree_page.tree is None:
+                break
+
+            for entry in tree_page.tree:
+                result.add(entry.sha)
+
+            if tree_page.truncated:
+                page += 1
+            else:
+                break
+
+        return result
diff --git a/reviewboard/hostingsvcs/forgejo/errors.py b/reviewboard/hostingsvcs/forgejo/errors.py
new file mode 100644
index 0000000000000000000000000000000000000000..52ad520ca8950882263583c3d5cc47c62f852cbf

--- /dev/null
+++ b/reviewboard/hostingsvcs/forgejo/errors.py
@@ -0,0 +1,15 @@
+"""Error definitions for Forgejo.
+
+Version Added:
+    7.1
+"""
+
+from __future__ import annotations
+
+
+class APITokenNameInUseError(Exception):
+    """Error for when an API token name is already in use.
+
+    Version Added:
+        7.1
+    """
diff --git a/reviewboard/hostingsvcs/forgejo/forms.py b/reviewboard/hostingsvcs/forgejo/forms.py
new file mode 100644
index 0000000000000000000000000000000000000000..79e421cc4c243de131ef469ee6e47dd755eb6241

--- /dev/null
+++ b/reviewboard/hostingsvcs/forgejo/forms.py
@@ -0,0 +1,36 @@
+"""Hosting service support for Forgejo.
+
+Version Added:
+    7.1
+"""
+
+from __future__ import annotations
+
+from django import forms
+from django.utils.translation import gettext_lazy as _
+
+from reviewboard.hostingsvcs.base.forms import BaseHostingServiceRepositoryForm
+
+
+class ForgejoForm(BaseHostingServiceRepositoryForm):
+    """Hosting service form for Forgejo.
+
+    Version Added:
+        7.1
+    """
+
+    repository_owner = forms.CharField(
+        label=_('Repository owner'),
+        max_length=128,
+        required=True,
+        widget=forms.TextInput(attrs={'size': '60'}),
+        help_text=_('The username of the owner of the repository.'),
+    )
+
+    repository_name = forms.CharField(
+        label=_('Repository name'),
+        max_length=128,
+        required=True,
+        widget=forms.TextInput(attrs={'size': '60'}),
+        help_text=_('The name of the repository.'),
+    )
diff --git a/reviewboard/hostingsvcs/forgejo/service.py b/reviewboard/hostingsvcs/forgejo/service.py
new file mode 100644
index 0000000000000000000000000000000000000000..b6685182f94b4c937ba1d00def99b486afaef70a

--- /dev/null
+++ b/reviewboard/hostingsvcs/forgejo/service.py
@@ -0,0 +1,437 @@
+"""Hosting service for Forgejo.
+
+Version Added:
+    7.1
+"""
+
+from __future__ import annotations
+
+import logging
+import uuid
+from typing import TYPE_CHECKING
+from urllib.parse import urljoin, urlparse
+
+from django.template.loader import render_to_string
+from django.urls import path
+from django.utils.translation import gettext_lazy as _
+
+from reviewboard.admin.server import build_server_url, get_server_url
+from reviewboard.hostingsvcs.base.bug_tracker import BaseBugTracker
+from reviewboard.hostingsvcs.base.hosting_service import BaseHostingService
+from reviewboard.hostingsvcs.errors import HostingServiceError
+from reviewboard.hostingsvcs.forgejo.client import ForgejoClient
+from reviewboard.hostingsvcs.forgejo.errors import APITokenNameInUseError
+from reviewboard.hostingsvcs.forgejo.forms import ForgejoForm
+from reviewboard.hostingsvcs.forgejo.views import WebHookView
+from reviewboard.scmtools.crypto_utils import encrypt_password
+from reviewboard.site.urlresolvers import local_site_reverse
+
+if TYPE_CHECKING:
+    from collections.abc import Sequence
+
+    from django.http import HttpRequest
+    from django.utils.safestring import SafeString
+
+    from reviewboard.hostingsvcs.base.bug_tracker import BugInfo
+    from reviewboard.scmtools.core import Branch, Commit
+    from reviewboard.scmtools.models import Repository
+
+
+logger = logging.getLogger(__name__)
+
+
+class Forgejo(BaseHostingService, BaseBugTracker):
+    """Hosting service for Forgejo.
+
+    Version Added:
+        7.1
+    """
+
+    hosting_service_id = 'forgejo'
+    name = _('Forgejo')
+
+    client_class = ForgejoClient
+    form = ForgejoForm
+
+    has_repository_hook_instructions = True
+    needs_authorization = True
+    self_hosted = True
+    supported_scmtools = ['Git']
+    supports_bug_trackers = True
+    supports_post_commit = True
+    supports_repositories = True
+    supports_two_factor_auth = True
+
+    bug_tracker_field = \
+        '%(hosting_url)s/%(repository_owner)s/%(repository_name)s/issues/%%s'
+    repository_fields = {
+        'Git': {
+            'path': '%(hosting_url)s/%(repository_owner)s/'
+                    '%(repository_name)s.git',
+        },
+    }
+
+    repository_url_patterns = [
+        path('hooks/close-submitted/',
+             view=WebHookView.as_view(),
+             name='forgejo-hooks-close-submitted'),
+    ]
+
+    # Set this for typing.
+    client: ForgejoClient
+
+    def is_authorized(self) -> bool:
+        """Return whether the account has stored authorization.
+
+        This will check to see if we have stored an API token for the account.
+        It does not validate that the token still works.
+
+        Returns:
+            bool:
+            ``True`` if the account has a saved API token. ``False``,
+            otherwise.
+        """
+        return 'api_token' in self.account.data
+
+    def authorize(
+        self,
+        *,
+        username: str | None,
+        password: str | None,
+        hosting_url: str | None,
+        two_factor_auth_code: (str | None) = None,
+        **kwargs,
+    ) -> None:
+        """Authorize an account for Forgejo.
+
+        Args:
+            username (str):
+                The username for the account.
+
+            password (str):
+                The password for the account.
+
+            hosting_url (str):
+                The URL of the Forgejo server.
+
+            two_factor_auth_code (str, optional):
+                The two-factor authentication code provided by the user.
+
+            **kwargs (dict, unused):
+                Extra keyword arguments containing values from the
+                repository's configuration.
+
+        Raises:
+            reviewboard.hostingsvcs.errors.AuthorizationError:
+                The credentials provided were not valid.
+
+            reviewboard.hostingsvcs.errors.TwoFactorAuthCodeRequiredError:
+                A two-factor authentication code is required to authorize
+                this account. The request must be retried with the same
+                credentials and with the ``two_factor_auth_code`` parameter
+                provided.
+
+            reviewboard.hostingsvcs.errors.HostingServiceError:
+                A non-authentication error occurred.
+        """
+        assert username is not None
+        assert password is not None
+        assert hosting_url is not None
+
+        attempts = 0
+        max_tries = 5
+
+        server_name = urlparse(get_server_url()).hostname
+        assert server_name is not None
+
+        while attempts < max_tries:
+            token_name = self._create_api_token_name(server_name)
+
+            try:
+                api_token = self.client.create_api_token(
+                    token_name=token_name,
+                    hosting_url=hosting_url,
+                    username=username,
+                    password=password,
+                    two_factor_auth_code=two_factor_auth_code)
+            except APITokenNameInUseError:
+                attempts += 1
+                continue
+
+            break
+        else:
+            logger.error('Forgejo: Failed to create an API token with a '
+                         'unique name for user %s on server %s after %d '
+                         'attempts.',
+                         username, hosting_url, max_tries)
+
+            raise HostingServiceError(
+                _(
+                    'Unable to create a Forgejo API token with a unique name '
+                    'after {tries} attempts for user {username} on server '
+                    '{hosting_url}'
+                ).format(
+                    tries=max_tries,
+                    username=username,
+                    hosting_url=hosting_url,
+                )
+            )
+
+        self.account.data['api_token'] = encrypt_password(api_token)
+        self.account.save()
+
+    def check_repository(
+        self,
+        *,
+        repository_owner: str,
+        repository_name: str,
+        **kwargs,
+    ) -> None:
+        """Check the validity of a repository configuration.
+
+        This checks that the data provided in the repository form is valid.
+
+        Args:
+            repository_owner (str):
+                The repository owner.
+
+            repository_name (str):
+                The name of the repository.
+
+            **kwargs (dict):
+                Additional keyword arguments from the repository form.
+
+        Raises:
+            reviewboard.hostingsvcs.errors.RepositoryError:
+                The repository is not valid.
+        """
+        self.client.check_repository(
+            hosting_url=self.account.hosting_url,
+            repository_owner=repository_owner,
+            repository_name=repository_name)
+
+    def get_file(
+        self,
+        repository: Repository,
+        path: str,
+        revision: str,
+        *args,
+        **kwargs,
+    ) -> bytes:
+        """Return the requested file.
+
+        Args:
+            repository (reviewboard.scmtools.models.Repository):
+                The repository to retrieve the file from.
+
+            path (str):
+                The file path.
+
+            revision (str):
+                The revision the file should be retrieved from.
+
+            *args (tuple, unused):
+                Unused positional arguments.
+
+            **kwargs (dict, unused):
+                Unused keyword arguments.
+
+        Returns:
+            bytes:
+            The contents of the file.
+
+        Raises:
+            reviewboard.scmtools.errors.FileNotFoundError:
+                The file could not be found or the API could not be accessed.
+        """
+        return self.client.get_blob(
+            hosting_url=self.account.hosting_url,
+            repository=repository,
+            path=path,
+            sha=revision)
+
+    def get_branches(
+        self,
+        repository: Repository,
+    ) -> Sequence[Branch]:
+        """Return a list of all branches in the repositories.
+
+        This should be implemented by subclasses, and is expected to return a
+        list of Branch objects. One (and only one) of those objects should have
+        the "default" field set to True.
+
+        Args:
+            repository (reviewboard.scmtools.models.Repository):
+                The repository for which branches should be returned.
+
+        Returns:
+            list of reviewboard.scmtools.core.Branch:
+            The branches.
+
+        Raises:
+            reviewboard.hostingsvcs.errors.HostingServiceError:
+                There was an error fetching branches.
+        """
+        return self.client.get_branches(
+            hosting_url=self.account.hosting_url,
+            repository=repository)
+
+    def get_commits(
+        self,
+        repository: Repository,
+        branch: (str | None) = None,
+        start: (str | None) = None,
+    ) -> Sequence[Commit]:
+        """Return a list of commits backward in history from a given point.
+
+        Args:
+            repository (reviewboard.scmtools.models.Repository):
+                The repository to retrieve commits from.
+
+            branch (str, optional):
+                The branch to retrieve from.
+
+                If this is not provided, the default branch will be used.
+
+            start (str, optional):
+                An optional starting revision.
+
+                If this is not provided, the most recent commits will be
+                returned.
+
+        Returns:
+            list of reviewboard.scmtools.core.Commit:
+            The retrieved commits.
+
+        Raises:
+            reviewboard.hostingsvcs.errors.HostingServiceError:
+                There was an error fetching commits.
+        """
+        return self.client.get_commits(
+            hosting_url=self.account.hosting_url,
+            repository=repository,
+            branch=branch,
+            start=start)
+
+    def get_change(
+        self,
+        repository: Repository,
+        revision: str,
+    ) -> Commit:
+        """Return an individual change.
+
+        Args:
+            repository (reviewboard.scmtools.models.Repository):
+                The repository to get the change from.
+
+            revision (str):
+                The revision to retrieve.
+
+        Returns:
+            reviewboard.scmtools.core.Commit:
+            The change.
+
+        Raises:
+            reviewboard.hostingsvcs.errors.HostingServiceError:
+                There was an error fetching the commit.
+        """
+        return self.client.get_change(
+            hosting_url=self.account.hosting_url,
+            repository=repository,
+            revision=revision)
+
+    def get_bug_info_uncached(
+        self,
+        repository: Repository,
+        bug_id: str,
+    ) -> BugInfo:
+        """Return the information for the specified bug
+
+        Args:
+            repository (reviewboard.scmtools.models.Repository):
+                The repository object.
+
+            bug_id (str):
+                The ID of the bug to fetch.
+
+        Returns:
+            reviewboard.hostingsvcs.bugtracker.BugInfo:
+            Information about the bug.
+        """
+        return self.client.get_bug_info(
+            hosting_url=self.account.hosting_url,
+            repository=repository,
+            bug_id=bug_id)
+
+    def get_repository_hook_instructions(
+        self,
+        request: HttpRequest,
+        repository: Repository,
+    ) -> SafeString:
+        """Return instructions for setting up incoming WebHooks.
+
+        Args:
+            request (django.http.HttpRequest):
+                The request from the client.
+
+            repository (reviewboard.scmtools.models.Repository):
+                The repository to return instructions for.
+
+        Returns:
+            django.utils.safestring.SafeString:
+            The HTML to send to the client for hook instructions.
+        """
+        repository_owner = repository.extra_data['repository_owner']
+        repository_name = repository.extra_data['repository_name']
+
+        add_webhook_url = urljoin(
+            self.account.hosting_url,
+            f'{repository_owner}/{repository_name}/settings/hooks/forgejo/new')
+
+        webhook_endpoint_url = build_server_url(local_site_reverse(
+            'forgejo-hooks-close-submitted',
+            local_site=repository.local_site,
+            kwargs={
+                'repository_id': repository.pk,
+                'hosting_service_id': repository.hosting_account.service_name,
+            },
+        ))
+
+        example_id = 123
+        example_url = build_server_url(local_site_reverse(
+            'review-request-detail',
+            local_site=repository.local_site,
+            kwargs={
+                'review_request_id': example_id,
+            }))
+
+        return render_to_string(
+            template_name='hostingsvcs/forgejo/repo_hook_instructions.html',
+            request=request,
+            context={
+                'example_id': example_id,
+                'example_url': example_url,
+                'repository': repository,
+                'server_url': get_server_url(),
+                'add_webhook_url': add_webhook_url,
+                'webhook_endpoint_url': webhook_endpoint_url,
+                'hook_uuid': repository.get_or_create_hooks_uuid(),
+            })
+
+    def _create_api_token_name(
+        self,
+        server_name: str,
+    ) -> str:
+        """Return a name to use for a new API token.
+
+        Args:
+            server_name (str):
+                The hostname of the Review Board server.
+
+        Returns:
+            str:
+            A name to use for the new API token.
+        """
+        id = uuid.uuid4().hex[:6]
+
+        return f'reviewboard-{server_name}-{id}'
diff --git a/reviewboard/hostingsvcs/forgejo/views.py b/reviewboard/hostingsvcs/forgejo/views.py
new file mode 100644
index 0000000000000000000000000000000000000000..783968b9c0afa95e739aea99531d1706f51eff60

--- /dev/null
+++ b/reviewboard/hostingsvcs/forgejo/views.py
@@ -0,0 +1,153 @@
+"""Views for the Forgejo hosting service.
+
+Version Added:
+    7.1
+"""
+
+from __future__ import annotations
+
+import hashlib
+import hmac
+import logging
+from collections import defaultdict
+from typing import TYPE_CHECKING
+
+from django.http import HttpResponse, HttpResponseBadRequest
+from django.views import View
+from pydantic import BaseModel, ValidationError
+
+from reviewboard.admin.server import get_server_url
+from reviewboard.hostingsvcs.hook_utils import (
+    close_all_review_requests,
+    get_git_branch_name,
+    get_repository_for_hook,
+    get_review_request_id,
+)
+
+if TYPE_CHECKING:
+    from django.http import HttpRequest
+
+
+logger = logging.getLogger(__name__)
+
+
+class PushWebHookPayload(BaseModel):
+    """Data for the push event WebHook payload.
+
+    Version Added:
+        7.1
+    """
+
+    class _Commit(BaseModel):
+        id: str
+        message: str
+
+    commits: list[_Commit]
+    ref: str
+
+
+class WebHookView(View):
+    """WebHook handler for Forgejo.
+
+    Version Added:
+        7.1
+    """
+
+    def post(
+        self,
+        request: HttpRequest,
+        *,
+        local_site_name: (str | None) = None,
+        repository_id: int,
+        hosting_service_id: str,
+    ) -> HttpResponse:
+        """Handle a POST request to the view.
+
+        Args:
+            request (django.http.HttpRequest):
+                The HTTP request from the Forgejo server.
+
+            local_site_name (str):
+                The Local Site name, if available.
+
+            repository_id (int):
+                The ID of the repository.
+
+            hosting_service_id (str):
+                The name of the hosting service.
+
+        Returns:
+            django.http.HttpResponse:
+            The response to send back to the server.
+        """
+        hook_event = request.META.get('HTTP_X_FORGEJO_EVENT')
+
+        if hook_event != 'push':
+            return HttpResponseBadRequest(
+                'Only the "push" event is supported.')
+
+        repository = get_repository_for_hook(
+            repository_id=repository_id,
+            hosting_service_id=hosting_service_id,
+            local_site_name=local_site_name)
+
+        signature = request.META.get('HTTP_X_FORGEJO_SIGNATURE')
+
+        if not signature:
+            logger.error('Forgejo WebHook: received POST with a missing '
+                         'signature (repository %r).',
+                         repository)
+
+            return HttpResponseBadRequest(
+                'X-Forgejo-Signature header was missing or blank.')
+
+        m = hmac.new(repository.get_or_create_hooks_uuid().encode(),
+                     request.body, hashlib.sha256)
+
+        if m.hexdigest() != signature:
+            logger.error('Forgejo WebHook: received incorrect signature '
+                         '(repository %r).',
+                         repository)
+
+            return HttpResponseBadRequest(
+                'Payload signature could not be validated.')
+
+        try:
+            payload = PushWebHookPayload.model_validate_json(request.body)
+        except ValidationError as e:
+            logger.error('Forgejo WebHook: Could not validate payload content '
+                         '(repository %r): %s',
+                         repository, e)
+
+            return HttpResponseBadRequest(
+                'Payload could not be parsed, see Review Board log for '
+                'details.')
+
+        server_url = get_server_url(request=request)
+        review_request_id_to_commits: defaultdict[int | None, list[str]] = \
+            defaultdict(list)
+        branch_name = get_git_branch_name(payload.ref)
+
+        for commit in payload.commits:
+            review_request_id = get_review_request_id(
+                commit_message=commit.message,
+                server_url=server_url,
+                commit_id=commit.id,
+                repository=repository)
+            assert review_request_id is not None
+
+            review_request_id_to_commits[review_request_id].append(
+                f'{branch_name} ({commit.id[:7]})')
+
+        if review_request_id_to_commits:
+            logger.info(
+                'Forgejo WebHook: Closing review requests %s',
+                ', '.join(str(key) for key in review_request_id_to_commits))
+
+            close_all_review_requests(
+                review_request_id_to_commits=review_request_id_to_commits,
+                local_site_name=local_site_name,
+                repository=repository,
+                hosting_service_id=hosting_service_id)
+
+        return HttpResponse()
diff --git a/reviewboard/hostingsvcs/tests/test_forgejo.py b/reviewboard/hostingsvcs/tests/test_forgejo.py
new file mode 100644
index 0000000000000000000000000000000000000000..ad0da28d9bb0fab58b77d58aae6e0ff0120370e1

--- /dev/null
+++ b/reviewboard/hostingsvcs/tests/test_forgejo.py
@@ -0,0 +1,1279 @@
+"""Unit tests for the Forgejo hosting service.
+
+Version Added:
+    7.1
+"""
+
+from __future__ import annotations
+
+import base64
+import hashlib
+import hmac
+import json
+from typing import TYPE_CHECKING
+
+import kgb
+from djblets.testing.decorators import add_fixtures
+
+from reviewboard.hostingsvcs.errors import (
+    AuthorizationError,
+    HostingServiceError,
+    TwoFactorAuthCodeRequiredError,
+)
+from reviewboard.hostingsvcs.forgejo import Forgejo
+from reviewboard.hostingsvcs.forgejo.client import ForgejoClient
+from reviewboard.hostingsvcs.forgejo.errors import APITokenNameInUseError
+from reviewboard.hostingsvcs.hook_utils import logger
+from reviewboard.hostingsvcs.testing import HostingServiceTestCase
+from reviewboard.reviews.models import ReviewRequest
+from reviewboard.scmtools.crypto_utils import (
+    decrypt_password,
+    encrypt_password,
+)
+from reviewboard.scmtools.errors import FileNotFoundError
+from reviewboard.site.models import LocalSite
+from reviewboard.site.urlresolvers import local_site_reverse
+
+if TYPE_CHECKING:
+    from django.http import HttpResponse
+
+
+class ForgejoTestCase(HostingServiceTestCase[Forgejo]):
+    """Base class for Forgejo test suites.
+
+    Version Added:
+        7.1
+    """
+
+    service_name = 'forgejo'
+
+    default_account_data = {
+        'api_token': encrypt_password('test_token_123'),
+    }
+
+    default_repository_extra_data = {
+        'repository_owner': 'testuser',
+        'repository_name': 'testrepo',
+    }
+
+    default_hosting_url = 'https://forge.example.com'
+    default_use_hosting_url = True
+
+
+class ForgejoTests(ForgejoTestCase):
+    """Unit tests for the Forgejo hosting service.
+
+    Version Added:
+        7.1
+    """
+
+    def test_service_support(self) -> None:
+        """Testing Forgejo service support capabilities"""
+        assert self.service_class is not None
+        self.assertTrue(self.service_class.supports_repositories)
+        self.assertTrue(self.service_class.supports_post_commit)
+        self.assertTrue(self.service_class.supports_two_factor_auth)
+        self.assertTrue(self.service_class.self_hosted)
+        self.assertEqual(self.service_class.supported_scmtools, ['Git'])
+
+    def test_get_repository_fields(self) -> None:
+        """Testing Forgejo.get_repository_fields"""
+        fields = self.get_repository_fields(
+            'Git',
+            fields={
+                'repository_owner': 'myowner',
+                'repository_name': 'myrepo',
+            }
+        )
+
+        self.assertEqual(fields, {
+            'path': 'https://forge.example.com/myowner/myrepo.git',
+        })
+
+    def test_is_authorized_with_token(self) -> None:
+        """Testing Forgejo.is_authorized with API token"""
+        account = self.create_hosting_account()
+        service = account.service
+
+        self.assertTrue(service.is_authorized())
+
+    def test_is_authorized_without_token(self) -> None:
+        """Testing Forgejo.is_authorized without API token"""
+        account = self.create_hosting_account(data={})
+        service = account.service
+
+        self.assertFalse(service.is_authorized())
+
+    def test_authorize_success(self) -> None:
+        """Testing Forgejo.authorize with valid credentials"""
+        access_token_data = {
+            'id': 1,
+            'name': 'Review Board',
+            'scopes': ['read:issue', 'read:organization', 'read:repository',
+                       'read:user'],
+            'sha1': 'new_test_token_456',
+            'token_last_eight': '456',
+        }
+
+        account = self.create_hosting_account(data={})
+        service = account.service
+
+        self.assertFalse(service.is_authorized())
+
+        handler = self.make_handler_for_paths({
+            '/api/v1/users/myuser/tokens': {
+                'payload': self.dump_json(access_token_data),
+                'headers': {'Content-Type': 'application/json'},
+            },
+        })
+
+        with self.setup_http_test(http_request_func=handler,
+                                  expected_http_calls=1):
+            service.authorize(
+                username='myuser',
+                password='testpass',
+                hosting_url='https://forge.example.com',
+                credentials={}
+            )
+
+        self.assertTrue(service.is_authorized())
+        self.assertEqual(decrypt_password(account.data['api_token']),
+                         'new_test_token_456')
+
+    def test_authorize_with_two_factor_auth(self) -> None:
+        """Testing Forgejo.authorize with two-factor authentication code"""
+        access_token_data = {
+            'id': 1,
+            'name': 'Review Board',
+            'scopes': ['read:issue', 'read:organization', 'read:repository',
+                       'read:user'],
+            'sha1': 'new_test_token_456',
+            'token_last_eight': '456',
+        }
+
+        account = self.create_hosting_account(data={})
+        service = account.service
+
+        handler = self.make_handler_for_paths({
+            '/api/v1/users/myuser/tokens': {
+                'payload': self.dump_json(access_token_data),
+                'headers': {'Content-Type': 'application/json'},
+            },
+        })
+
+        with self.setup_http_test(http_request_func=handler,
+                                  expected_http_calls=1):
+            service.authorize(
+                username='myuser',
+                password='testpass',
+                hosting_url='https://forge.example.com',
+                credentials={},
+                two_factor_auth_code='123456'
+            )
+
+        self.assertTrue(service.is_authorized())
+
+    def test_authorize_invalid_credentials(self) -> None:
+        """Testing Forgejo.authorize with invalid credentials"""
+        error_data = {
+            'message': 'invalid provided password',
+            'url': 'https://forge.example.com/api/v1/users/myuser/tokens',
+        }
+
+        account = self.create_hosting_account(data={})
+        service = account.service
+
+        handler = self.make_handler_for_paths({
+            '/api/v1/users/myuser/tokens': {
+                'payload': self.dump_json(error_data),
+                'status_code': 401,
+                'headers': {'Content-Type': 'application/json'},
+            },
+        })
+
+        message = 'login or password is incorrect'
+
+        with (self.setup_http_test(http_request_func=handler,
+                                   expected_http_calls=1),
+              self.assertRaisesMessage(AuthorizationError,
+                                       message)):
+            service.authorize(
+                username='myuser',
+                password='wrongpass',
+                hosting_url='https://forge.example.com',
+                credentials={}
+            )
+
+    def test_authorize_two_factor_required(self) -> None:
+        """Testing Forgejo.authorize when two-factor auth is required"""
+        error_data = {
+            'message': 'invalid provided OTP',
+            'url': 'https://forge.example.com/api/v1/users/myuser/tokens',
+        }
+
+        account = self.create_hosting_account(data={})
+        service = account.service
+
+        handler = self.make_handler_for_paths({
+            '/api/v1/users/myuser/tokens': {
+                'payload': self.dump_json(error_data),
+                'status_code': 400,
+                'headers': {'Content-Type': 'application/json'},
+            },
+        })
+
+        with (self.setup_http_test(http_request_func=handler,
+                                   expected_http_calls=1),
+              self.assertRaises(TwoFactorAuthCodeRequiredError)):
+            service.authorize(
+                username='myuser',
+                password='testpass',
+                hosting_url='https://forge.example.com',
+                credentials={}
+            )
+
+    def test_authorize_with_token_name_retry_success(self) -> None:
+        """Testing Forgejo.authorize with successful retry after duplicate
+        token name
+        """
+        account = self.create_hosting_account(data={})
+        service = account.service
+        client = service.client
+        assert isinstance(client, ForgejoClient)
+
+        self.spy_on(
+            client.create_api_token,
+            op=kgb.SpyOpMatchInOrder([
+                {
+                    'op': kgb.SpyOpRaise(APITokenNameInUseError()),
+                },
+                {
+                    'op': kgb.SpyOpReturn('retry_success_token_456'),
+                }
+            ]))
+
+        service.authorize(
+            username='myuser',
+            password='testpass',
+            hosting_url='https://forge.example.com',
+            credentials={}
+        )
+
+        self.assertTrue(service.is_authorized())
+        self.assertEqual(decrypt_password(account.data['api_token']),
+                         'retry_success_token_456')
+
+    def test_authorize_with_max_retries_exceeded(self) -> None:
+        """Testing Forgejo.authorize fails after maximum retries due to
+        duplicate token names
+        """
+        account = self.create_hosting_account(data={})
+        service = account.service
+        client = service.client
+        assert isinstance(client, ForgejoClient)
+
+        self.spy_on(client.create_api_token,
+                    op=kgb.SpyOpRaise(APITokenNameInUseError()))
+
+        message = (
+            'Unable to create a Forgejo API token with a unique name after 5 '
+            'attempts for user myuser on server https://forge.example.com'
+        )
+
+        with self.assertRaisesMessage(HostingServiceError, message):
+            service.authorize(
+                username='myuser',
+                password='testpass',
+                hosting_url='https://forge.example.com',
+                credentials={}
+            )
+
+        self.assertFalse(service.is_authorized())
+
+        spy = self.get_spy(client.create_api_token)
+        self.assertEqual(len(spy.calls), 5)
+
+    def test_create_api_token_name(self) -> None:
+        """Testing Forgejo._create_api_token_name generates unique names"""
+        account = self.create_hosting_account(data={})
+        service = account.service
+        assert isinstance(service, Forgejo)
+
+        # Test that token names are generated with correct format
+        token_name1 = service._create_api_token_name('example.com')
+        token_name2 = service._create_api_token_name('example.com')
+
+        # Both should start with 'reviewboard-example.com-'
+        self.assertTrue(token_name1.startswith('reviewboard-example.com-'))
+        self.assertTrue(token_name2.startswith('reviewboard-example.com-'))
+
+        # They should be different (due to UUID)
+        self.assertNotEqual(token_name1, token_name2)
+
+        # Both should have the expected structure
+        parts1 = token_name1.split('-')
+        parts2 = token_name2.split('-')
+
+        self.assertEqual(len(parts1), 3)
+        self.assertEqual(len(parts2), 3)
+
+        self.assertEqual(parts1[0], 'reviewboard')
+        self.assertEqual(parts1[1], 'example.com')
+        self.assertEqual(len(parts1[2]), 6)  # UUID hex should be 6 chars
+
+        self.assertEqual(parts2[0], 'reviewboard')
+        self.assertEqual(parts2[1], 'example.com')
+        self.assertEqual(len(parts2[2]), 6)
+
+    def test_get_file(self) -> None:
+        """Testing Forgejo.get_file"""
+        file_content = b'def main():\n    print("Hello, World!")\n'
+        encoded_content = base64.b64encode(file_content).decode('utf-8')
+
+        blob_data = {
+            'content': encoded_content,
+            'encoding': 'base64',
+            'sha': 'abc123def456',
+            'size': len(file_content),
+            'url': 'https://forge.example.com/api/v1/repos/testuser/testrepo/'
+                   'git/blobs/abc123def456',
+        }
+
+        account = self.create_hosting_account()
+        repository = self.create_repository(hosting_account=account)
+        service = account.service
+
+        handler = self.make_handler_for_paths({
+            '/api/v1/repos/testuser/testrepo/git/blobs/abc123def456': {
+                'payload': self.dump_json(blob_data),
+                'headers': {'Content-Type': 'application/json'},
+            },
+        })
+
+        with self.setup_http_test(http_request_func=handler,
+                                  expected_http_calls=1):
+            content = service.get_file(
+                repository=repository,
+                path='main.py',
+                revision='abc123def456'
+            )
+
+        self.assertEqual(content, file_content)
+
+    def test_get_file_not_found(self) -> None:
+        """Testing Forgejo.get_file with file not found"""
+        account = self.create_hosting_account()
+        repository = self.create_repository(hosting_account=account)
+        service = account.service
+
+        handler = self.make_handler_for_paths({
+            '/api/v1/repos/testuser/testrepo/git/blobs/abc123def456': {
+                'status_code': 400,
+                'payload': json.dumps({
+                    'message': 'object does not exist [id: abc123def456, '
+                               'rel_path: ]',
+                    'url': '/api/v1/repos/testuser/testrepo/git/blobs/'
+                           'abc123def456',
+                }).encode(),
+            },
+        })
+
+        with (self.setup_http_test(http_request_func=handler,
+                                   expected_http_calls=1),
+              self.assertRaises(FileNotFoundError)):
+            service.get_file(
+                repository=repository,
+                path='nonexistent.py',
+                revision='abc123def456'
+            )
+
+    def test_get_file_repo_invalid(self) -> None:
+        """Testing Forgejo.get_file with an invalid repository name"""
+        account = self.create_hosting_account()
+        repository = self.create_repository(hosting_account=account)
+        service = account.service
+
+        handler = self.make_handler_for_paths({
+            '/api/v1/repos/testuser/testrepo/git/blobs/abc123def456': {
+                'status_code': 404,
+                'payload': json.dumps({
+                    'errors': [],
+                    'message': "The target couldn't be found.",
+                    'url': '/api/v1/repos/testuser/testrepo/git/blobs/'
+                           'abc123def456',
+                }).encode(),
+            },
+        })
+
+        with (self.setup_http_test(http_request_func=handler,
+                                   expected_http_calls=1),
+              self.assertRaises(HostingServiceError)):
+            service.get_file(
+                repository=repository,
+                path='nonexistent.py',
+                revision='abc123def456'
+            )
+
+    def test_get_branches(self) -> None:
+        """Testing Forgejo.get_branches"""
+        repo_data = {
+            'clone_url': 'https://forge.example.com/testuser/testrepo.git',
+            'default_branch': 'main',
+            'description': 'My repo',
+            'id': 1,
+            'name': 'testrepo',
+            'private': False,
+        }
+
+        refs_data = [
+            {
+                'object': {
+                    'sha': 'abc123',
+                    'type': 'commit',
+                    'url': 'https://forge.example.com/api/v1/repos/testuser/'
+                           'testrepo/git/commits/abc123',
+                },
+                'ref': 'refs/heads/main',
+                'url': 'https://forge.example.com/api/v1/repos/testuser/'
+                       'testrepo/git/refs/heads/main',
+            },
+            {
+                'object': {
+                    'sha': 'def456',
+                    'type': 'commit',
+                    'url': 'https://forge.example.com/api/v1/repos/testuser/'
+                           'testrepo/git/commits/def456',
+                },
+                'ref': 'refs/heads/feature',
+                'url': 'https://forge.example.com/api/v1/repos/testuser/'
+                       'testrepo/git/refs/heads/feature',
+            }
+        ]
+
+        account = self.create_hosting_account()
+        repository = self.create_repository(hosting_account=account)
+        service = account.service
+
+        handler = self.make_handler_for_paths({
+            '/api/v1/repos/testuser/testrepo': {
+                'payload': self.dump_json(repo_data),
+                'headers': {'Content-Type': 'application/json'},
+            },
+            '/api/v1/repos/testuser/testrepo/git/refs': {
+                'payload': self.dump_json(refs_data),
+                'headers': {'Content-Type': 'application/json'},
+            },
+        })
+
+        with self.setup_http_test(http_request_func=handler,
+                                  expected_http_calls=2):
+            branches = service.get_branches(repository)
+
+        self.assertEqual(len(branches), 2)
+
+        main_branch = next(b for b in branches if b.id == 'main')
+        self.assertEqual(main_branch.commit, 'abc123')
+        self.assertTrue(main_branch.default)
+
+        feature_branch = next(b for b in branches if b.id == 'feature')
+        self.assertEqual(feature_branch.commit, 'def456')
+        self.assertFalse(feature_branch.default)
+
+    def test_get_commits(self) -> None:
+        """Testing Forgejo.get_commits"""
+        commits_data = [
+            {
+                'commit': {
+                    'author': {
+                        'date': '2023-01-01T12:00:00Z',
+                        'email': 'user@example.com',
+                        'name': 'Test User',
+                    },
+                    'committer': {
+                        'date': '2023-01-01T12:00:00Z',
+                        'email': 'user@example.com',
+                        'name': 'Test User',
+                    },
+                    'message': 'Initial commit',
+                    'tree': {
+                        'created': '2023-01-01T12:00:00Z',
+                        'sha': 'tree123',
+                        'url': 'https://forge.example.com/api/v1/repos/'
+                               'testuser/testrepo/git/trees/tree123',
+                    }
+                },
+                'created': '2023-01-01T12:00:00Z',
+                'parents': [],
+                'sha': 'commit123',
+            }
+        ]
+
+        account = self.create_hosting_account()
+        repository = self.create_repository(hosting_account=account)
+        service = account.service
+
+        handler = self.make_handler_for_paths({
+            '/api/v1/repos/testuser/testrepo/commits': {
+                'payload': self.dump_json(commits_data),
+                'headers': {'Content-Type': 'application/json'},
+            },
+        })
+
+        with self.setup_http_test(http_request_func=handler,
+                                  expected_http_calls=1):
+            commits = service.get_commits(repository, branch='main')
+
+        self.assertEqual(len(commits), 1)
+        commit = commits[0]
+        self.assertEqual(commit.author_name, 'Test User')
+        self.assertEqual(commit.id, 'commit123')
+        self.assertEqual(commit.message, 'Initial commit')
+        self.assertEqual(commit.parent, '')
+
+    def test_get_change(self) -> None:
+        """Testing Forgejo.get_change"""
+        commit_data = {
+            'commit': {
+                'author': {
+                    'date': '2023-01-01T12:00:00Z',
+                    'email': 'user@example.com',
+                    'name': 'Test User',
+                },
+                'committer': {
+                    'date': '2023-01-01T12:00:00Z',
+                    'email': 'user@example.com',
+                    'name': 'Test User',
+                },
+                'message': 'Add new feature',
+                'tree': {
+                    'created': '2023-01-01T12:00:00Z',
+                    'sha': 'tree123',
+                    'url': 'https://forge.example.com/api/v1/repos/testuser/'
+                           'testrepo/git/trees/tree123',
+                }
+            },
+            'created': '2023-01-01T12:00:00Z',
+            'parents': [
+                {
+                    'created': '2023-01-01T11:00:00Z',
+                    'sha': 'parent123',
+                    'url': '/api/v1/repos/testuser/testrepo/git/commits/'
+                           'parent123',
+                },
+            ],
+            'sha': 'commit456',
+        }
+
+        tree_data = {
+            'page': 1,
+            'sha': 'tree123',
+            'total_count': 1,
+            'tree': [
+                {
+                    'path': 'file.py',
+                    'sha': 'file123',
+                    'size': 100,
+                    'type': 'blob',
+                }
+            ],
+            'truncated': False
+        }
+
+        diff_content = (
+            b'diff --git a/file.py b/file.py\n'
+            b'index abc123..def456 100644\n'
+            b'--- a/file.py\n'
+            b'+++ b/file.py\n'
+            b'@@ -1 +1,2 @@\n'
+            b' print("Hello")\n'
+            b'+print("World")\n'
+        )
+
+        account = self.create_hosting_account()
+        repository = self.create_repository(hosting_account=account)
+        service = account.service
+
+        handler = self.make_handler_for_paths({
+            '/api/v1/repos/testuser/testrepo/git/commits/commit456': {
+                'payload': self.dump_json(commit_data),
+                'headers': {'Content-Type': 'application/json'},
+            },
+            '/api/v1/repos/testuser/testrepo/git/commits/commit456.diff': {
+                'payload': diff_content,
+                'headers': {'Content-Type': 'text/plain'},
+            },
+            '/api/v1/repos/testuser/testrepo/git/trees/commit456': {
+                'payload': self.dump_json(tree_data),
+                'headers': {'Content-Type': 'application/json'},
+            },
+            '/api/v1/repos/testuser/testrepo/git/trees/parent123': {
+                'payload': self.dump_json(tree_data),
+                'headers': {'Content-Type': 'application/json'},
+            },
+        })
+
+        with self.setup_http_test(http_request_func=handler,
+                                  expected_http_calls=4):
+            commit = service.get_change(repository, 'commit456')
+
+        self.assertEqual(commit.author_name, 'Test User')
+        self.assertEqual(commit.id, 'commit456')
+        self.assertEqual(commit.message, 'Add new feature')
+        self.assertEqual(commit.parent, 'parent123')
+        self.assertIsNotNone(commit.diff)
+
+    def test_get_bug_info_uncached(self) -> None:
+        """Testing Forgejo.get_bug_info_uncached"""
+        issue_data = {
+            'body': 'This is a test issue description with multiple lines.\n'
+                    'It should be properly returned in the bug info.',
+            'id': 123,
+            'state': 'open',
+            'title': 'Test Issue Title',
+        }
+
+        account = self.create_hosting_account()
+        repository = self.create_repository(hosting_account=account)
+        service = account.service
+        assert isinstance(service, Forgejo)
+
+        handler = self.make_handler_for_paths({
+            '/api/v1/repos/testuser/testrepo/issues/123': {
+                'payload': self.dump_json(issue_data),
+                'headers': {'Content-Type': 'application/json'},
+            },
+        })
+
+        with self.setup_http_test(http_request_func=handler,
+                                  expected_http_calls=1):
+            bug_info = service.get_bug_info_uncached(repository, '123')
+
+        self.assertEqual(bug_info, {
+            'description':
+                'This is a test issue description with multiple lines.\n'
+                'It should be properly returned in the bug info.',
+            'description_text_format': 'markdown',
+            'status': 'open',
+            'summary': 'Test Issue Title',
+        })
+
+    def test_get_bug_info_uncached_issue_not_found(self) -> None:
+        """Testing Forgejo.get_bug_info_uncached with issue not found"""
+        account = self.create_hosting_account()
+        repository = self.create_repository(hosting_account=account)
+        service = account.service
+        assert isinstance(service, Forgejo)
+
+        handler = self.make_handler_for_paths({
+            '/api/v1/repos/testuser/testrepo/issues/999': {
+                'status_code': 404,
+                'payload': json.dumps({
+                    'message': 'Not Found',
+                    'url': '/api/v1/repos/testuser/testrepo/issues/999',
+                }).encode(),
+            },
+        })
+
+        with self.setup_http_test(http_request_func=handler,
+                                  expected_http_calls=1):
+            bug_info = service.get_bug_info_uncached(repository, '999')
+
+        self.assertEqual(bug_info, {
+            'description': '',
+            'status': '',
+            'summary': '',
+        })
+
+    def test_get_bug_info_uncached_api_error(self) -> None:
+        """Testing Forgejo.get_bug_info_uncached with API error"""
+        account = self.create_hosting_account()
+        repository = self.create_repository(hosting_account=account)
+        service = account.service
+        assert isinstance(service, Forgejo)
+
+        handler = self.make_handler_for_paths({
+            '/api/v1/repos/testuser/testrepo/issues/456': {
+                'status_code': 500,
+                'payload': json.dumps({
+                    'message': 'Internal server error',
+                    'url': '/api/v1/repos/testuser/testrepo/issues/456',
+                }).encode(),
+            },
+        })
+
+        with self.setup_http_test(http_request_func=handler,
+                                  expected_http_calls=1):
+            bug_info = service.get_bug_info_uncached(repository, '456')
+
+        self.assertEqual(bug_info, {
+            'description': '',
+            'status': '',
+            'summary': '',
+        })
+
+
+class ForgejoClientTests(ForgejoTestCase):
+    """Unit tests for the ForgejoClient
+
+    Version Added:
+        7.1
+    """
+
+    def test_get_http_credentials_with_token(self) -> None:
+        """Testing ForgejoClient.get_http_credentials with API token"""
+        account = self.create_hosting_account()
+        client = account.service.client
+
+        credentials = client.get_http_credentials(account)
+
+        expected_token = decrypt_password(account.data['api_token'])
+        self.assertEqual(expected_token, 'test_token_123')
+        self.assertEqual(credentials, {
+            'header': {
+                'Authorization': f'token {expected_token}',
+            },
+        })
+
+    def test_get_http_credentials_without_token(self) -> None:
+        """Testing ForgejoClient.get_http_credentials without API token"""
+        account = self.create_hosting_account(data={})
+        client = account.service.client
+
+        credentials = client.get_http_credentials(account)
+
+        self.assertEqual(credentials, {})
+
+    def test_create_api_token(self) -> None:
+        """Testing ForgejoClient.create_api_token"""
+        access_token_data = {
+            'id': 1,
+            'name': 'Review Board',
+            'scopes': ['read:issue', 'read:organization', 'read:repository',
+                       'read:user'],
+            'sha1': 'new_api_token_789',
+            'token_last_eight': '789',
+        }
+
+        account = self.create_hosting_account()
+        client = account.service.client
+        assert isinstance(client, ForgejoClient)
+
+        handler = self.make_handler_for_paths({
+            '/api/v1/users/myuser/tokens': {
+                'payload': self.dump_json(access_token_data),
+                'headers': {'Content-Type': 'application/json'},
+            },
+        })
+
+        with self.setup_http_test(http_request_func=handler,
+                                  expected_http_calls=1):
+            token = client.create_api_token(
+                token_name='Review Board',
+                hosting_url='https://forge.example.com',
+                username='myuser',
+                password='testpass',
+            )
+
+        self.assertEqual(token, 'new_api_token_789')
+
+    def test_get_blob(self) -> None:
+        """Testing ForgejoClient.get_blob"""
+        file_content = b'print("Hello from Forgejo!")'
+        encoded_content = base64.b64encode(file_content).decode('utf-8')
+
+        blob_data = {
+            'content': encoded_content,
+            'encoding': 'base64',
+            'sha': 'blob789',
+            'size': len(file_content),
+            'url': 'https://forge.example.com/api/v1/repos/testuser/testrepo/'
+                   'git/blobs/blob789',
+        }
+
+        account = self.create_hosting_account()
+        repository = self.create_repository(hosting_account=account)
+        client = account.service.client
+        assert isinstance(client, ForgejoClient)
+
+        handler = self.make_handler_for_paths({
+            '/api/v1/repos/testuser/testrepo/git/blobs/blob789': {
+                'payload': self.dump_json(blob_data),
+                'headers': {'Content-Type': 'application/json'},
+            },
+        })
+
+        with self.setup_http_test(http_request_func=handler,
+                                  expected_http_calls=1):
+            content = client.get_blob(
+                hosting_url='https://forge.example.com',
+                repository=repository,
+                path='hello.py',
+                sha='blob789',
+            )
+
+        self.assertEqual(content, file_content)
+
+    def test_get_blob_unsupported_encoding(self) -> None:
+        """Testing ForgejoClient.get_blob with unsupported encoding"""
+        blob_data = {
+            'content': 'raw content',
+            'encoding': 'raw',
+            'sha': 'blob789',
+            'size': 11,
+            'url': 'https://forge.example.com/api/v1/repos/testuser/testrepo/'
+                   'git/blobs/blob789',
+        }
+
+        account = self.create_hosting_account()
+        repository = self.create_repository(hosting_account=account)
+        client = account.service.client
+        assert isinstance(client, ForgejoClient)
+
+        handler = self.make_handler_for_paths({
+            '/api/v1/repos/testuser/testrepo/git/blobs/blob789': {
+                'payload': self.dump_json(blob_data),
+                'headers': {'Content-Type': 'application/json'},
+            },
+        })
+
+        message = (
+            'Forgejo returned data with an unknown encoding. See the Review '
+            'Board log file for details.'
+        )
+
+        with (self.setup_http_test(http_request_func=handler,
+                                   expected_http_calls=1),
+              self.assertRaisesMessage(HostingServiceError,
+                                       message)):
+            client.get_blob(
+                hosting_url='https://forge.example.com',
+                repository=repository,
+                path='hello.py',
+                sha='blob789',
+            )
+
+    def test_api_root_generation(self) -> None:
+        """Testing ForgejoClient._get_api_root"""
+        account = self.create_hosting_account()
+        client = account.service.client
+        assert isinstance(client, ForgejoClient)
+
+        # Test with trailing slash
+        self.assertEqual(
+            client._get_api_root('https://forge.example.com/'),
+            'https://forge.example.com/api/v1/'
+        )
+
+        # Test without trailing slash
+        self.assertEqual(
+            client._get_api_root('https://forge.example.com'),
+            'https://forge.example.com/api/v1/'
+        )
+
+    def test_api_repo_root_generation(self) -> None:
+        """Testing ForgejoClient._get_api_repo_root"""
+        account = self.create_hosting_account()
+        repository = self.create_repository(hosting_account=account)
+        client = account.service.client
+        assert isinstance(client, ForgejoClient)
+
+        repo_root = client._get_api_repo_root(
+            'https://forge.example.com',
+            repository
+        )
+
+        self.assertEqual(
+            repo_root,
+            'https://forge.example.com/api/v1/repos/testuser/testrepo'
+        )
+
+
+class ForgejoCloseSubmittedHookTests(ForgejoTestCase):
+    """Unit tests for the Forgejo close-submitted webhook.
+
+    Version Added:
+        7.1
+    """
+
+    fixtures = ['test_users', 'test_scmtools']
+
+    def test_hook(self) -> None:
+        """Testing Forgejo close_submitted hook with event=push"""
+        self._test_post_commit_hook()
+
+    @add_fixtures(['test_site'])
+    def test_hook_with_local_site(self) -> None:
+        """Testing Forgejo close_submitted hook with event=push and using a
+        Local Site
+        """
+        self._test_post_commit_hook(
+            LocalSite.objects.get(name=self.local_site_name))
+
+    @add_fixtures(['test_site'])
+    def test_hook_with_unpublished_review_request(self) -> None:
+        """Testing Forgejo close_submitted hook with event=push and an
+        un-published review request
+        """
+        self._test_post_commit_hook(publish=False)
+
+    def test_hook_with_invalid_repo(self) -> None:
+        """Testing Forgejo close_submitted hook with event=push and invalid
+        repository
+        """
+        repository = self.create_repository()
+
+        review_request = self.create_review_request(repository=repository,
+                                                    publish=True)
+        self.assertTrue(review_request.public)
+        self.assertEqual(review_request.status, review_request.PENDING_REVIEW)
+
+        url = local_site_reverse(
+            'forgejo-hooks-close-submitted',
+            kwargs={
+                'repository_id': repository.pk,
+                'hosting_service_id': 'forgejo',
+            })
+
+        response = self._post_commit_hook_payload(
+            post_url=url,
+            review_request_url=review_request.get_absolute_url(),
+            secret=repository.get_or_create_hooks_uuid())
+        self.assertEqual(response.status_code, 404)
+
+        review_request = ReviewRequest.objects.get(pk=review_request.pk)
+        self.assertTrue(review_request.public)
+        self.assertEqual(review_request.status, review_request.PENDING_REVIEW)
+        self.assertEqual(review_request.changedescs.count(), 0)
+
+    def test_hook_with_invalid_site(self) -> None:
+        """Testing Forgejo close_submitted hook with event=push and invalid
+        Local Site
+        """
+        account = self.create_hosting_account()
+        repository = self.create_repository(hosting_account=account)
+
+        review_request = self.create_review_request(repository=repository,
+                                                    publish=True)
+        self.assertTrue(review_request.public)
+        self.assertEqual(review_request.status, review_request.PENDING_REVIEW)
+
+        url = local_site_reverse(
+            'forgejo-hooks-close-submitted',
+            local_site_name='badsite',
+            kwargs={
+                'repository_id': repository.pk,
+                'hosting_service_id': 'forgejo',
+            })
+
+        response = self._post_commit_hook_payload(
+            post_url=url,
+            review_request_url=review_request.get_absolute_url(),
+            secret=repository.get_or_create_hooks_uuid())
+        self.assertEqual(response.status_code, 404)
+
+        review_request = ReviewRequest.objects.get(pk=review_request.pk)
+        self.assertTrue(review_request.public)
+        self.assertEqual(review_request.status, review_request.PENDING_REVIEW)
+        self.assertEqual(review_request.changedescs.count(), 0)
+
+    def test_hook_with_invalid_service_id(self) -> None:
+        """Testing Forgejo close_submitted hook with event=push and invalid
+        hosting service ID
+        """
+        # We'll test against GitHub for this test.
+        account = self.create_hosting_account()
+        account.service_name = 'github'
+        account.save()
+
+        repository = self.create_repository(hosting_account=account)
+
+        review_request = self.create_review_request(repository=repository,
+                                                    publish=True)
+        self.assertTrue(review_request.public)
+        self.assertEqual(review_request.status, review_request.PENDING_REVIEW)
+
+        url = local_site_reverse(
+            'forgejo-hooks-close-submitted',
+            kwargs={
+                'repository_id': repository.pk,
+                'hosting_service_id': 'forgejo',
+            })
+
+        response = self._post_commit_hook_payload(
+            post_url=url,
+            review_request_url=review_request.get_absolute_url(),
+            secret=repository.get_or_create_hooks_uuid())
+        self.assertEqual(response.status_code, 404)
+
+        review_request = ReviewRequest.objects.get(pk=review_request.pk)
+        self.assertTrue(review_request.public)
+        self.assertEqual(review_request.status, review_request.PENDING_REVIEW)
+        self.assertEqual(review_request.changedescs.count(), 0)
+
+    def test_hook_with_invalid_event(self) -> None:
+        """Testing Forgejo close_submitted hook with invalid event"""
+        account = self.create_hosting_account()
+        repository = self.create_repository(hosting_account=account)
+        review_request = self.create_review_request(repository=repository,
+                                                    publish=True)
+        self.assertTrue(review_request.public)
+        self.assertEqual(review_request.status, review_request.PENDING_REVIEW)
+
+        url = local_site_reverse(
+            'forgejo-hooks-close-submitted',
+            kwargs={
+                'repository_id': repository.pk,
+                'hosting_service_id': 'forgejo',
+            })
+
+        response = self._post_commit_hook_payload(
+            post_url=url,
+            review_request_url=review_request.get_absolute_url(),
+            secret=repository.get_or_create_hooks_uuid(),
+            event='issues')
+        self.assertEqual(response.status_code, 400)
+
+        review_request = ReviewRequest.objects.get(pk=review_request.pk)
+        self.assertTrue(review_request.public)
+        self.assertEqual(review_request.status, review_request.PENDING_REVIEW)
+        self.assertEqual(review_request.changedescs.count(), 0)
+
+    def test_hook_with_missing_signature(self) -> None:
+        """Testing Forgejo close_submitted hook with missing signature"""
+        account = self.create_hosting_account()
+        repository = self.create_repository(hosting_account=account)
+        review_request = self.create_review_request(repository=repository,
+                                                    publish=True)
+        self.assertTrue(review_request.public)
+        self.assertEqual(review_request.status, review_request.PENDING_REVIEW)
+
+        url = local_site_reverse(
+            'forgejo-hooks-close-submitted',
+            kwargs={
+                'repository_id': repository.pk,
+                'hosting_service_id': 'forgejo',
+            })
+
+        payload = self.dump_json({
+            'ref': 'refs/heads/main',
+            'commits': [
+                {
+                    'id': '1c44b461cebe5874a857c51a4a13a849a4d1e52d',
+                    'message': (
+                        f'This is my fancy commit\n'
+                        f'\n'
+                        f'Reviewed at '
+                        f'http://example.com'
+                        f'{review_request.get_absolute_url()}'
+                    ),
+                },
+            ]
+        })
+
+        response = self.client.post(
+            url,
+            payload,
+            content_type='application/json',
+            HTTP_X_FORGEJO_EVENT='push')
+        self.assertEqual(response.status_code, 400)
+
+        review_request = ReviewRequest.objects.get(pk=review_request.pk)
+        self.assertTrue(review_request.public)
+        self.assertEqual(review_request.status, review_request.PENDING_REVIEW)
+        self.assertEqual(review_request.changedescs.count(), 0)
+
+    def test_hook_with_invalid_signature(self) -> None:
+        """Testing Forgejo close_submitted hook with invalid signature"""
+        account = self.create_hosting_account()
+        repository = self.create_repository(hosting_account=account)
+        review_request = self.create_review_request(repository=repository,
+                                                    publish=True)
+        self.assertTrue(review_request.public)
+        self.assertEqual(review_request.status, review_request.PENDING_REVIEW)
+
+        url = local_site_reverse(
+            'forgejo-hooks-close-submitted',
+            kwargs={
+                'repository_id': repository.pk,
+                'hosting_service_id': 'forgejo',
+            })
+
+        response = self._post_commit_hook_payload(
+            post_url=url,
+            review_request_url=review_request.get_absolute_url(),
+            secret='bad-secret')
+        self.assertEqual(response.status_code, 400)
+
+        review_request = ReviewRequest.objects.get(pk=review_request.pk)
+        self.assertTrue(review_request.public)
+        self.assertEqual(review_request.status, review_request.PENDING_REVIEW)
+        self.assertEqual(review_request.changedescs.count(), 0)
+
+    def test_hook_with_invalid_review_requests(self) -> None:
+        """Testing Forgejo close_submitted hook with event=push and invalid
+        review requests
+        """
+        self.spy_on(logger.error)
+
+        account = self.create_hosting_account()
+        repository = self.create_repository(hosting_account=account)
+
+        review_request = self.create_review_request(repository=repository,
+                                                    publish=True)
+        self.assertTrue(review_request.public)
+        self.assertEqual(review_request.status, review_request.PENDING_REVIEW)
+
+        url = local_site_reverse(
+            'forgejo-hooks-close-submitted',
+            kwargs={
+                'repository_id': repository.pk,
+                'hosting_service_id': 'forgejo',
+            })
+
+        response = self._post_commit_hook_payload(
+            post_url=url,
+            review_request_url='/r/9999/',
+            secret=repository.get_or_create_hooks_uuid())
+        self.assertEqual(response.status_code, 200)
+
+        review_request = ReviewRequest.objects.get(pk=review_request.pk)
+        self.assertTrue(review_request.public)
+        self.assertEqual(review_request.status, review_request.PENDING_REVIEW)
+        self.assertEqual(review_request.changedescs.count(), 0)
+
+        self.assertSpyCalledWith(
+            logger.error,
+            'close_all_review_requests: Review request #%s does not exist.',
+            9999)
+
+    def test_hook_with_invalid_json(self) -> None:
+        """Testing Forgejo close_submitted hook with invalid JSON payload"""
+        account = self.create_hosting_account()
+        repository = self.create_repository(hosting_account=account)
+        review_request = self.create_review_request(repository=repository,
+                                                    publish=True)
+        self.assertTrue(review_request.public)
+        self.assertEqual(review_request.status, review_request.PENDING_REVIEW)
+
+        url = local_site_reverse(
+            'forgejo-hooks-close-submitted',
+            kwargs={
+                'repository_id': repository.pk,
+                'hosting_service_id': 'forgejo',
+            })
+
+        invalid_payload = b'invalid json'
+        secret = repository.get_or_create_hooks_uuid()
+        m = hmac.new(secret.encode('utf-8'), invalid_payload, hashlib.sha256)
+
+        response = self.client.post(
+            url,
+            invalid_payload,
+            content_type='application/json',
+            HTTP_X_FORGEJO_EVENT='push',
+            HTTP_X_FORGEJO_SIGNATURE=m.hexdigest())
+        self.assertEqual(response.status_code, 400)
+
+        review_request = ReviewRequest.objects.get(pk=review_request.pk)
+        self.assertTrue(review_request.public)
+        self.assertEqual(review_request.status, review_request.PENDING_REVIEW)
+        self.assertEqual(review_request.changedescs.count(), 0)
+
+    def _test_post_commit_hook(
+        self,
+        local_site: (LocalSite | None) = None,
+        publish: bool = True,
+    ) -> None:
+        """Test posting to a commit hook.
+
+        This will simulate pushing a commit and posting the resulting webhook
+        payload from Forgejo to the handler for the hook.
+
+        Args:
+            local_site (reviewboard.site.models.LocalSite, optional):
+                The Local Site owning the review request.
+
+            publish (bool, optional):
+                Whether to test with a published review request.
+        """
+        account = self.create_hosting_account(local_site=local_site)
+        repository = self.create_repository(hosting_account=account,
+                                            local_site=local_site)
+
+        review_request = self.create_review_request(repository=repository,
+                                                    local_site=local_site,
+                                                    publish=publish)
+        self.assertEqual(review_request.status, review_request.PENDING_REVIEW)
+
+        url = local_site_reverse(
+            'forgejo-hooks-close-submitted',
+            local_site=local_site,
+            kwargs={
+                'repository_id': repository.pk,
+                'hosting_service_id': 'forgejo',
+            })
+
+        response = self._post_commit_hook_payload(
+            post_url=url,
+            review_request_url=review_request.get_absolute_url(),
+            secret=repository.get_or_create_hooks_uuid())
+        self.assertEqual(response.status_code, 200)
+
+        review_request = ReviewRequest.objects.get(pk=review_request.pk)
+        self.assertTrue(review_request.public)
+        self.assertEqual(review_request.status, review_request.SUBMITTED)
+        self.assertEqual(review_request.changedescs.count(), 1)
+
+        changedesc = review_request.changedescs.get()
+        self.assertEqual(changedesc.text, 'Pushed to main (1c44b46)')
+
+    def _post_commit_hook_payload(
+        self,
+        post_url: str,
+        review_request_url: str,
+        secret: str,
+        event: str = 'push'
+    ) -> HttpResponse:
+        """Post a payload for a hook for testing.
+
+        Args:
+            post_url (str):
+                The URL to post to.
+
+            review_request_url (str):
+                The URL of the review request being represented in the
+                payload.
+
+            secret (str):
+                The HMAC secret for the message.
+
+            event (str, optional):
+                The webhook event.
+
+        Returns:
+            django.http.HttpResponse:
+            The response from the post request.
+        """
+        payload = json.dumps({
+            # NOTE: This payload only contains the content we make
+            #       use of in the hook.
+            'ref': 'refs/heads/main',
+            'commits': [
+                {
+                    'id': '1c44b461cebe5874a857c51a4a13a849a4d1e52d',
+                    'message':
+                        f'This is my fancy commit\n'
+                        f'\n'
+                        f'Reviewed at http://example.com{review_request_url}',
+                },
+            ]
+        }).encode()
+
+        m = hmac.new(secret.encode('utf-8'), payload, hashlib.sha256)
+
+        return self.client.post(  # type:ignore
+            post_url,
+            payload,
+            content_type='application/json',
+            HTTP_X_FORGEJO_EVENT=event,
+            HTTP_X_FORGEJO_SIGNATURE=m.hexdigest())
diff --git a/reviewboard/templates/hostingsvcs/forgejo/repo_hook_instructions.html b/reviewboard/templates/hostingsvcs/forgejo/repo_hook_instructions.html
new file mode 100644
index 0000000000000000000000000000000000000000..41a23cef2988be93dd629193726b996a636c76ea

--- /dev/null
+++ b/reviewboard/templates/hostingsvcs/forgejo/repo_hook_instructions.html
@@ -0,0 +1,49 @@
+{% load i18n %}
+
+<p>
+{% blocktrans %}
+ {{PRODUCT_NAME}} supports closing review requests that are referenced in
+ commit messages for any changes pushed to this repository. These references
+ are in the form of:
+{% endblocktrans %}
+</p>
+<pre>Reviewed at {{example_url}}</pre>
+<p>{% trans "Or:" %}</p>
+<pre>Review Request #{{example_id}}</pre>
+<p>
+{% blocktrans %}
+ To configure this,
+ <a href="{{add_webhook_url}}" target="_blank">add a new WebHook</a>
+ for your Forgejo repository and set:
+{% endblocktrans %}
+</p>
+<table class="sample-fields">
+ <tr>
+  <th>Payload URL:</th>
+  <td><code>{{webhook_endpoint_url}}</code></td>
+ </tr>
+ <tr>
+  <th>POST content type:</th>
+  <td><code>application/json</code></td>
+ </tr>
+ <tr>
+  <th>Secret:</th>
+  <td><code>{{hook_uuid}}</code></td>
+ </tr>
+ <tr>
+  <th>Trigger on:</th>
+  <td>Push events</td>
+ </tr>
+</table>
+<p>
+{% blocktrans %}
+ Then click <strong>Add webhook</strong>. You're done!
+{% endblocktrans %}
+</p>
+
+<p>
+ By default, Forgejo limits outgoing WebHooks. You may need to edit the
+ <a href="https://forgejo.org/docs/latest/admin/config-cheat-sheet/#webhook-webhook">
+ webhook.ALLOWED_HOST_LIST</a> setting to allow delivery to your Review Board
+ server.
+</p>
