diff --git a/docs/manual/extending/coderef/index.rst b/docs/manual/extending/coderef/index.rst
index 8346c8d6d7d67cb29508cd2ef61704ebde0fd04e..192acca467895de530dbec0622d36a4a27cd29c4 100644
--- a/docs/manual/extending/coderef/index.rst
+++ b/docs/manual/extending/coderef/index.rst
@@ -211,6 +211,7 @@
    :toctree: python
 
    reviewboard.hostingsvcs.base
+   reviewboard.hostingsvcs.base.bug_tracker
    reviewboard.hostingsvcs.base.client
    reviewboard.hostingsvcs.base.forms
    reviewboard.hostingsvcs.base.hosting_service
diff --git a/reviewboard/hostingsvcs/base/__init__.py b/reviewboard/hostingsvcs/base/__init__.py
index 39004625831b71803b2cd3f2a98027471979cba5..41ccf8a21fd7a8f69d13b657fcf81499a1c7d086 100644
--- a/reviewboard/hostingsvcs/base/__init__.py
+++ b/reviewboard/hostingsvcs/base/__init__.py
@@ -3,6 +3,7 @@
 .. autosummary::
    :nosignatures:
 
+   ~reviewboard.hostingsvcs.base.bug_tracker.BaseBugTracker
    ~reviewboard.hostingsvcs.base.client.HostingServiceClient
    ~reviewboard.hostingsvcs.base.hosting_service.BaseHostingService
    ~reviewboard.hostingsvcs.base.http.HostingServiceHTTPRequest
@@ -14,6 +15,7 @@
     6.0
 """
 
+from reviewboard.hostingsvcs.base.bug_tracker import BaseBugTracker
 from reviewboard.hostingsvcs.base.client import HostingServiceClient
 from reviewboard.hostingsvcs.base.hosting_service import BaseHostingService
 from reviewboard.hostingsvcs.base.http import (HostingServiceHTTPRequest,
@@ -23,6 +25,7 @@
 
 
 __all__ = [
+    'BaseBugTracker',
     'BaseHostingService',
     'HostingServiceClient',
     'HostingServiceHTTPRequest',
diff --git a/reviewboard/hostingsvcs/base/bug_tracker.py b/reviewboard/hostingsvcs/base/bug_tracker.py
new file mode 100644
index 0000000000000000000000000000000000000000..56b7c8dcf44164f89985210b45112066ba8774d0

--- /dev/null
+++ b/reviewboard/hostingsvcs/base/bug_tracker.py
@@ -0,0 +1,144 @@
+"""An interface to a bug tracker.
+
+Version Changed:
+    8.0:
+    Renamed this module from ``reviewboard.hostingsvcs.bugtracker``.
+"""
+
+from __future__ import annotations
+
+from typing import TypedDict, TYPE_CHECKING
+
+from djblets.cache.backend import cache_memoize
+
+if TYPE_CHECKING:
+    from collections.abc import Sequence
+    from typing import ClassVar
+
+    from typing_extensions import NotRequired
+
+    from reviewboard.scmtools.models import Repository
+
+
+class BugInfo(TypedDict):
+    """Information about a bug.
+
+    Version Added:
+        8.0
+    """
+
+    #: The description of the bug.
+    description: str
+
+    #: The text format of the description.
+    #:
+    #: This can be an empty string, or one of 'plain', 'markdown', or 'html'.
+    description_text_format: NotRequired[str]
+
+    #: A one-line summary of the bug.
+    summary: str
+
+    #: The bug's status.
+    status: str
+
+
+class BaseBugTracker:
+    """An interface to a bug tracker.
+
+    Bug tracker subclasses are used to enable interaction with different
+    bug trackers.
+
+    Version Changed:
+        8.0:
+        Moved and renamed from
+        ``reviewboard.hostingsvcs.bugtracker.BugTracker``.
+    """
+
+    #: The name of the bug tracker
+    name: ClassVar[str | None] = None
+
+    def get_bug_info(
+        self,
+        repository: Repository,
+        bug_id: str,
+    ) -> BugInfo:
+        """Return the information for the specified bug.
+
+        This should return a :py:class:`BugInfo` dictionary.
+
+        This is cached for 60 seconds to reduce the number of queries to the
+        bug trackers and make things seem fast after the first infobox load,
+        but is still a short enough time to give relatively fresh data.
+
+        Args:
+            repository (reviewboard.scmtools.models.Repository):
+                The repository object.
+
+            bug_id (str):
+                The ID of the bug to fetch.
+
+        Returns:
+            BugInfo:
+            Information about the bug.
+        """
+        return cache_memoize(self.make_bug_cache_key(repository, bug_id),
+                             lambda: self.get_bug_info_uncached(repository,
+                                                                bug_id),
+                             expiration=60)
+
+    def get_bug_info_uncached(
+        self,
+        repository: Repository,
+        bug_id: str,
+    ) -> BugInfo:
+        """Return the information for the specified bug.
+
+        This should be implemented by subclasses.
+
+        Args:
+            repository (reviewboard.scmtools.models.Repository):
+                The repository object.
+
+            bug_id (str):
+                The ID of the bug to fetch.
+
+        Returns:
+            BugInfo:
+            Information about the bug.
+        """
+        return {
+            'summary': '',
+            'description': '',
+            'status': '',
+        }
+
+    def make_bug_cache_key(
+        self,
+        repository: Repository,
+        bug_id: str,
+    ) -> str | Sequence[str]:
+        """Return a key to use when caching fetched bug information.
+
+        Version Changed:
+            8.0:
+            Changed to return a list of strings for the cache key, which
+            :py:func:`djblets.cache.backend.cache_memoize` will use to create a
+            safely escaped cache key.
+
+        Args:
+            repository (reviewboard.scmtools.models.Repository):
+                The repository object.
+
+            bug_id (str):
+                The ID of the bug.
+
+        Returns:
+            list of str:
+            A key to use for the cache.
+        """
+        return [
+            'repository',
+            str(repository.pk),
+            'bug',
+            bug_id,
+        ]
diff --git a/reviewboard/hostingsvcs/bugtracker.py b/reviewboard/hostingsvcs/bugtracker.py
index bd990cb4d51f80279333779244494c4dfb1e9f6b..23e9aaef6e29cfcdbfa34857d6eff66aa7e91c02 100644
--- a/reviewboard/hostingsvcs/bugtracker.py
+++ b/reviewboard/hostingsvcs/bugtracker.py
@@ -1,46 +1,21 @@
-from typing import Optional
-
-from djblets.cache.backend import cache_memoize
-
-
-class BugTracker:
+"""Legacy imports for bug trackers."""
+
+from __future__ import annotations
+
+from housekeeping import ClassMovedMixin
+
+from reviewboard.deprecation import RemovedInReviewBoard10_0Warning
+from reviewboard.hostingsvcs.base import bug_tracker
+
+
+class BugTracker(ClassMovedMixin,
+                 bug_tracker.BaseBugTracker,
+                 warning_cls=RemovedInReviewBoard10_0Warning):
     """An interface to a bug tracker.
 
-    BugTracker subclasses are used to enable interaction with different
-    bug trackers.
+    Deprecated:
+        8.0:
+        This has been moved to :py:class:`reviewboard.hostingsvcs.base.
+        bug_tracker.BaseBugTracker`. The legacy import will be removed in
+        Review Board 10.
     """
-
-    name: Optional[str] = None
-
-    def get_bug_info(self, repository, bug_id):
-        """Get the information for the specified bug.
-
-        This should return a dictionary with 'summary', 'description', and
-        'status' keys.
-
-        This is cached for 60 seconds to reduce the number of queries to the
-        bug trackers and make things seem fast after the first infobox load,
-        but is still a short enough time to give relatively fresh data.
-        """
-        return cache_memoize(self.make_bug_cache_key(repository, bug_id),
-                             lambda: self.get_bug_info_uncached(repository,
-                                                                bug_id),
-                             expiration=60)
-
-    def get_bug_info_uncached(self, repository, bug_id):
-        """Get the information for the specified bug (implementation).
-
-        This should be implemented by subclasses, and should return a
-        dictionary with 'summary', 'description', and 'status' keys.
-        If any of those are unsupported by the given bug tracker, the unknown
-        values should be given as an empty string.
-        """
-        return {
-            'summary': '',
-            'description': '',
-            'status': '',
-        }
-
-    def make_bug_cache_key(self, repository, bug_id):
-        """Returns a key to use when caching fetched bug information."""
-        return 'repository-%s-bug-%s' % (repository.pk, bug_id)
diff --git a/reviewboard/hostingsvcs/bugzilla.py b/reviewboard/hostingsvcs/bugzilla.py
index 33d59c57d434f84099c82d7db86538917d352b79..439fd6daf7e6ac449b451afb853c7f840ad1ff01 100644
--- a/reviewboard/hostingsvcs/bugzilla.py
+++ b/reviewboard/hostingsvcs/bugzilla.py
@@ -1,13 +1,22 @@
+"""Hosting service for Bugzilla."""
+
+from __future__ import annotations
+
 import logging
+from typing import TYPE_CHECKING
 
 from django import forms
 from django.utils.translation import gettext_lazy as _
 
+from reviewboard.hostingsvcs.base.bug_tracker import BaseBugTracker
 from reviewboard.hostingsvcs.base.forms import BaseHostingServiceRepositoryForm
 from reviewboard.hostingsvcs.base.hosting_service import BaseHostingService
-from reviewboard.hostingsvcs.bugtracker import BugTracker
 from reviewboard.admin.validation import validate_bug_tracker_base_hosting_url
 
+if TYPE_CHECKING:
+    from reviewboard.hostingsvcs.base.bug_tracker import BugInfo
+    from reviewboard.scmtools.models import Repository
+
 
 logger = logging.getLogger(__name__)
 
@@ -24,33 +33,49 @@
         return self.cleaned_data['bugzilla_url'].rstrip('/')
 
 
-class Bugzilla(BaseHostingService, BugTracker):
+class Bugzilla(BaseHostingService, BaseBugTracker):
+    """Hosting service for Bugzilla."""
+
     name = 'Bugzilla'
     hosting_service_id = 'bugzilla'
     form = BugzillaForm
     bug_tracker_field = '%(bugzilla_url)s/show_bug.cgi?id=%%s'
     supports_bug_trackers = True
 
-    def get_bug_info_uncached(self, repository, bug_id):
-        """Get the bug info from the server."""
+    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.base.bug_tracker.BugInfo:
+            Information about the bug.
+        """
         # This requires making two HTTP requests: one for the summary and
         # status, and one to get the "first comment" (description).
         bug_id = str(bug_id)
 
-        result = {
+        result: BugInfo = {
             'summary': '',
             'description': '',
             'status': '',
         }
 
-        url = '%s/rest/bug/%s' % (
-            repository.extra_data['bug_tracker-bugzilla_url'],
-            bug_id)
+        bugzilla_url = repository.extra_data['bug_tracker-bugzilla_url']
+        url = f'{bugzilla_url}/rest/bug/{bug_id}'
 
         try:
-            rsp, headers = self.client.json_get(
-                '%s?include_fields=summary,status'
-                % url)
+            rsp = self.client.json_get(
+                f'{url}?include_fields=summary,status')[0]
             result['summary'] = rsp['bugs'][0]['summary']
             result['status'] = rsp['bugs'][0]['status']
         except Exception as e:
@@ -59,7 +84,7 @@
 
         try:
             url += '/comment'
-            rsp, headers = self.client.json_get(url)
+            rsp = self.client.json_get(url)[0]
             result['description'] = rsp['bugs'][bug_id]['comments'][0]['text']
         except Exception as e:
             logger.warning('Unable to fetch bugzilla data from %s: %s',
diff --git a/reviewboard/hostingsvcs/github.py b/reviewboard/hostingsvcs/github.py
index 0c7a9404e5b0a2640c6d5f3a40b8cc893d2c75d6..11071143b2f34ea4e01f40e59aede350f1d651d4 100644
--- a/reviewboard/hostingsvcs/github.py
+++ b/reviewboard/hostingsvcs/github.py
@@ -21,9 +21,9 @@
 from django.views.decorators.http import require_POST
 
 from reviewboard.admin.server import build_server_url, get_server_url
+from reviewboard.hostingsvcs.base.bug_tracker import BaseBugTracker
 from reviewboard.hostingsvcs.base.client import HostingServiceClient
 from reviewboard.hostingsvcs.base.hosting_service import BaseHostingService
-from reviewboard.hostingsvcs.bugtracker import BugTracker
 from reviewboard.hostingsvcs.errors import (AuthorizationError,
                                             HostingServiceError,
                                             InvalidPlanError,
@@ -48,7 +48,9 @@
 if TYPE_CHECKING:
     from urllib.error import URLError
 
+    from reviewboard.hostingsvcs.base.bug_tracker import BugInfo
     from reviewboard.hostingsvcs.base.http import HostingServiceHTTPRequest
+    from reviewboard.scmtools.models import Repository
 
 
 logger = logging.getLogger(__name__)
@@ -605,7 +607,7 @@
         return review_request_id_to_commits_map
 
 
-class GitHub(BaseHostingService, BugTracker):
+class GitHub(BaseHostingService, BaseBugTracker):
     name = _('GitHub')
     hosting_service_id = 'github'
     plans = [
@@ -1082,9 +1084,25 @@
                                 mirror_path=repo['mirror_url'],
                                 extra_data=repo)
 
-    def get_bug_info_uncached(self, repository, bug_id):
-        """Get the bug info from the server."""
-        result = {
+    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.base.bug_tracker.BugInfo:
+            Information about the bug.
+        """
+        result: BugInfo = {
             'summary': '',
             'description': '',
             'status': '',
diff --git a/reviewboard/hostingsvcs/jira.py b/reviewboard/hostingsvcs/jira.py
index 2ff5cd84cff9718318a2f3c103489e2631ba7fb4..9deaab988e2041f3a29a9917820dcbaa986c5785 100644
--- a/reviewboard/hostingsvcs/jira.py
+++ b/reviewboard/hostingsvcs/jira.py
@@ -1,4 +1,9 @@
+"""Hosting service for JIRA."""
+
+from __future__ import annotations
+
 import logging
+from typing import TYPE_CHECKING
 
 from django import forms
 from django.utils.translation import gettext_lazy as _
@@ -10,9 +15,13 @@
     has_jira = False
 
 from reviewboard.admin.validation import validate_bug_tracker_base_hosting_url
+from reviewboard.hostingsvcs.base.bug_tracker import BaseBugTracker
 from reviewboard.hostingsvcs.base.forms import BaseHostingServiceRepositoryForm
 from reviewboard.hostingsvcs.base.hosting_service import BaseHostingService
-from reviewboard.hostingsvcs.bugtracker import BugTracker
+
+if TYPE_CHECKING:
+    from reviewboard.hostingsvcs.base.bug_tracker import BugInfo
+    from reviewboard.scmtools.models import Repository
 
 
 logger = logging.getLogger(__name__)
@@ -30,7 +39,9 @@
         return self.cleaned_data['jira_url'].rstrip('/ ')
 
 
-class JIRA(BaseHostingService, BugTracker):
+class JIRA(BaseHostingService, BaseBugTracker):
+    """Hosting service for JIRA."""
+
     name = 'JIRA'
     hosting_service_id = 'jira'
     form = JIRAForm
@@ -42,9 +53,25 @@
 
         self.jira_client = None
 
-    def get_bug_info_uncached(self, repository, bug_id):
-        """Get the bug info from the server."""
-        result = {
+    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.base.bug_tracker.BugInfo:
+            Information about the bug.
+        """
+        result: BugInfo = {
             'summary': '',
             'description': '',
             'status': '',
@@ -59,8 +86,8 @@
                     }, max_retries=0)
                 except ValueError as e:
                     logger.warning(
-                        'Unable to initialize JIRAClient for server %s: %s'
-                        % (repository.extra_data['bug_tracker-jira_url'], e))
+                        'Unable to initialize JIRAClient for server %s: %s',
+                        repository.extra_data['bug_tracker-jira_url'], e)
                     return result
 
             try:
diff --git a/reviewboard/hostingsvcs/splat.py b/reviewboard/hostingsvcs/splat.py
index bd8f90896583b5684bfcb3d65266b473d1c13374..7355ff3386ba194f0a8ac8e0d97f4ea22435eee6 100644
--- a/reviewboard/hostingsvcs/splat.py
+++ b/reviewboard/hostingsvcs/splat.py
@@ -1,13 +1,20 @@
 """Support for Splat as a bug tracker."""
 
+from __future__ import annotations
+
 import logging
+from typing import TYPE_CHECKING
 
 from django import forms
 from django.utils.translation import gettext_lazy as _
 
+from reviewboard.hostingsvcs.base.bug_tracker import BaseBugTracker
 from reviewboard.hostingsvcs.base.forms import BaseHostingServiceRepositoryForm
 from reviewboard.hostingsvcs.base.hosting_service import BaseHostingService
-from reviewboard.hostingsvcs.bugtracker import BugTracker
+
+if TYPE_CHECKING:
+    from reviewboard.hostingsvcs.base.bug_tracker import BugInfo
+    from reviewboard.scmtools.models import Repository
 
 
 logger = logging.getLogger(__name__)
@@ -23,7 +30,7 @@
         widget=forms.TextInput(attrs={'size': 60}))
 
 
-class Splat(BaseHostingService, BugTracker):
+class Splat(BaseHostingService, BaseBugTracker):
     """The Splat bug tracker.
 
     Splat is a SaaS bugtracker hosted at https://hellosplat.com. It is owned
@@ -38,31 +45,35 @@
         'https://hellosplat.com/s/%(splat_org_name)s/tickets/%%s/'
     supports_bug_trackers = True
 
-    def get_bug_info_uncached(self, repository, bug_id):
-        """Return the bug info from the server.
+    def get_bug_info_uncached(
+        self,
+        repository: Repository,
+        bug_id: str,
+    ) -> BugInfo:
+        """Return the information for the specified bug.
 
         Args:
-            repository (reviewboard.scmtools.model.Repository):
-                The repository that is using Splat as a bug tracker.
+            repository (reviewboard.scmtools.models.Repository):
+                The repository object.
 
-            bug_id (unicode):
-                The bug identifier.
+            bug_id (str):
+                The ID of the bug to fetch.
 
         Returns:
-            dict:
-            A dictionary of the bug information.
+            reviewboard.hostingsvcs.base.bug_tracker.BugInfo:
+            Information about the bug.
         """
-        result = {
+        result: BugInfo = {
             'summary': '',
             'description': '',
             'description_text_format': '',
             'status': '',
         }
 
+        org_name = repository.extra_data['bug_tracker-splat_org_name']
         url = (
-            'https://hellosplat.com/api/orgs/%s/tickets/%s/?only-fields=status'
-            ',summary,text,text_format'
-            % (repository.extra_data['bug_tracker-splat_org_name'], bug_id)
+            f'https://hellosplat.com/api/orgs/{org_name}/tickets/{bug_id}/'
+            f'?only-fields=status,summary,text,text_format'
         )
 
         try:
diff --git a/reviewboard/reviews/views/bug_trackers.py b/reviewboard/reviews/views/bug_trackers.py
index 2288bd68685bd707c5764069bd64b6d9fe1c65fe..64882e5ef63eae97fb41799e842fa000c9f9e44e 100644
--- a/reviewboard/reviews/views/bug_trackers.py
+++ b/reviewboard/reviews/views/bug_trackers.py
@@ -13,7 +13,7 @@
 from django.utils.translation import gettext_lazy as _
 from django.views.generic.base import TemplateView, View
 
-from reviewboard.hostingsvcs.bugtracker import BugTracker
+from reviewboard.hostingsvcs.base.bug_tracker import BaseBugTracker
 from reviewboard.reviews.markdown_utils import render_markdown
 from reviewboard.reviews.views.mixins import ReviewRequestViewMixin
 from reviewboard.site.urlresolvers import local_site_reverse
@@ -81,7 +81,7 @@
             return HttpResponseNotFound(
                 _('Unable to find bug tracker service'))
 
-        if not isinstance(bug_tracker, BugTracker):
+        if not isinstance(bug_tracker, BaseBugTracker):
             return HttpResponseNotFound(
                 _('Bug tracker %s does not support metadata')
                 % bug_tracker.name)
