diff --git a/reviewboard/hostingsvcs/utils/paginator.py b/reviewboard/hostingsvcs/utils/paginator.py
new file mode 100644
index 0000000000000000000000000000000000000000..4335eaedcf534ef7dcbfdfc11ee53297150c1aa9
--- /dev/null
+++ b/reviewboard/hostingsvcs/utils/paginator.py
@@ -0,0 +1,269 @@
+from __future__ import unicode_literals
+
+from django.utils import six
+from django.utils.six.moves.urllib.parse import (parse_qs, urlencode,
+                                                 urlsplit, urlunsplit)
+
+
+class InvalidPageError(Exception):
+    """An error representing an invalid page access."""
+    pass
+
+
+class BasePaginator(object):
+    """Base class for a paginator used in the hosting services code.
+
+    This provides the basic state and stubbed functions for a simple
+    paginator. Subclasses can build upon this to offer more advanced
+    functionality.
+    """
+    def __init__(self, start=None, per_page=None):
+        self.start = start
+        self.per_page = per_page
+        self.page_data = None
+        self.total_count = None
+
+    @property
+    def has_prev(self):
+        """Returns whether there's a previous page available.
+
+        Subclasses must override this to provide a meaningful
+        return value.
+        """
+        raise NotImplementedError
+
+    @property
+    def has_next(self):
+        """Returns whether there's a next page available.
+
+        Subclasses must override this to provide a meaningful
+        return value.
+        """
+        raise NotImplementedError
+
+    def prev(self):
+        """Fetches the previous page, returning the page data.
+
+        Subclasses must override this to provide the logic for
+        fetching pages.
+
+        If there isn't a previous page available, this must raise
+        InvalidPageError.
+        """
+        raise NotImplementedError
+
+    def next(self):
+        """Fetches the previous page, returning the page data.
+
+        Subclasses must override this to provide the logic for
+        fetching pages.
+
+        If there isn't a next page available, this must raise
+        InvalidPageError.
+        """
+        raise NotImplementedError
+
+
+class APIPaginator(BasePaginator):
+    """Handles pagination for API requests to a hosting service.
+
+    Hosting services may provide subclasses of APIPaginator that can
+    handle paginating their specific APIs. These make it easy to fetch
+    pages of data from the API, and also works as a bridge for
+    Review Board's web API resources.
+
+    All APIPaginators are expected to take an instance of a
+    HostingServiceClient subclass, and the starting URL (without any
+    arguments for pagination).
+
+    Subclasses can access the HostingServiceClient through the ``client``
+    member of the paginator in order to perform requests against the
+    HostingService.
+    """
+    #: The optional query parameter name used to specify the start page in
+    #: a request.
+    start_query_param = None
+
+    #: The optional query parameter name used to specify the requested number
+    #: of results per page.
+    per_page_query_param = None
+
+    def __init__(self, client, url, query_params={}, *args, **kwargs):
+        super(APIPaginator, self).__init__(*args, **kwargs)
+
+        self.client = client
+        self.prev_url = None
+        self.next_url = None
+        self.page_headers = None
+
+        # Augment the URL with the provided query parameters.
+        query_params = query_params.copy()
+
+        if self.start_query_param and self.start:
+            query_params[self.start_query_param] = self.start
+
+        if self.per_page_query_param and self.per_page:
+            query_params[self.per_page_query_param] = self.per_page
+
+        self.url = self._add_query_params(url, query_params)
+
+        self._fetch_page()
+
+    @property
+    def has_prev(self):
+        """Returns whether there's a previous page available."""
+        return self.prev_url is not None
+
+    @property
+    def has_next(self):
+        """Returns whether there's a next page available."""
+        return self.next_url is not None
+
+    def prev(self):
+        """Fetches the previous page, returning the page data.
+
+        If there isn't a next page available, this will raise
+        InvalidPageError.
+        """
+        if not self.has_prev:
+            raise InvalidPageError
+
+        self.url = self.prev_url
+        return self._fetch_page()
+
+    def next(self):
+        """Fetches the next page, returning the page data.
+
+        If there isn't a next page available, this will raise
+        InvalidPageError.
+        """
+        if not self.has_next:
+            raise InvalidPageError
+
+        self.url = self.next_url
+        return self._fetch_page()
+
+    def fetch_url(self, url):
+        """Fetches the URL, returning information on the page.
+
+        This must be implemented by subclasses. It must return a dictionary
+        with the following fields:
+
+        * data        - The data from the page (generally as a list).
+        * headers     - The headers from the page response.
+        * total_count - The optional total number of items across all pages.
+        * per_page    - The optional limit on the number of items fetched
+                        on each page.
+        * prev_url    - The optional URL to the previous page.
+        * next_url    - The optional URL to the next page.
+        """
+        raise NotImplementedError
+
+    def _fetch_page(self):
+        """Fetches a page and extracts the information from it."""
+        page_info = self.fetch_url(self.url)
+
+        self.prev_url = page_info.get('prev_url')
+        self.next_url = page_info.get('next_url')
+        self.per_page = page_info.get('per_page', self.per_page)
+        self.page_data = page_info.get('data')
+        self.page_headers = page_info.get('headers')
+        self.total_count = page_info.get('total_count')
+
+        return self.page_data
+
+    def _add_query_params(self, url, new_query_params):
+        """Adds query parameters onto the given URL."""
+        scheme, netloc, path, query_string, fragment = urlsplit(url)
+        query_params = parse_qs(query_string)
+        query_params.update(new_query_params)
+        new_query_string = urlencode(
+            [
+                (key, value)
+                for key, value in sorted(six.iteritems(query_params),
+                                         key=lambda i: i[0])
+            ],
+            doseq=True)
+
+        return urlunsplit((scheme, netloc, path, new_query_string, fragment))
+
+
+class ProxyPaginator(BasePaginator):
+    """A paginator that proxies to another paginator, transforming data.
+
+    This attaches to another paginator, forwarding all requests and proxying
+    all data.
+
+    The ProxyPaginator can take the data returned from the other paginator
+    and normalize it, transforming it into a new form.
+
+    This is useful when a HostingService wants to return a paginator to
+    callers that represents data in a structured way, using an APIPaginator's
+    raw payloads as a backing.
+    """
+    def __init__(self, paginator, normalize_page_data_func=None):
+        # NOTE: We're not calling BasePaginator here, because we're actually
+        #       overriding all the properties it would set that we care about.
+        self.paginator = paginator
+        self.normalize_page_data_func = normalize_page_data_func
+        self.page_data = self.normalize_page_data(self.paginator.page_data)
+
+    @property
+    def has_prev(self):
+        """Returns whether there's a previous page available."""
+        return self.paginator.has_prev
+
+    @property
+    def has_next(self):
+        """Returns whether there's a next page available."""
+        return self.paginator.has_next
+
+    @property
+    def per_page(self):
+        """Returns the number of items requested per page."""
+        return self.paginator.per_page
+
+    @property
+    def total_count(self):
+        """Returns the number of items across all pages, if known."""
+        return self.paginator.total_count
+
+    def prev(self):
+        """Fetches the previous page, returning the page data.
+
+        If there isn't a next page available, this will raise
+        InvalidPageError.
+        """
+        return self._process_page(self.paginator.prev())
+
+    def next(self):
+        """Fetches the next page, returning the page data.
+
+        If there isn't a next page available, this will raise
+        InvalidPageError.
+        """
+        return self._process_page(self.paginator.next())
+
+    def normalize_page_data(self, data):
+        """Normalizes a page of data.
+
+        If ``normalize_page_data_func`` was passed on construction, this
+        will call it, passing in the page data. That will then be returned.
+
+        This can be overridden by subclasses that want to do more complex
+        processing without requiring ``normalize_page_data_func`` to be
+        passed in.
+        """
+        if callable(self.normalize_page_data_func):
+            data = self.normalize_page_data_func(data)
+
+        return data
+
+    def _process_page(self, page_data):
+        """Processes a page of data.
+
+        This will normalize the page data, store it, and return it.
+        """
+        self.page_data = self.normalize_page_data(page_data)
+
+        return self.page_data
diff --git a/reviewboard/hostingsvcs/utils/tests.py b/reviewboard/hostingsvcs/utils/tests.py
new file mode 100644
index 0000000000000000000000000000000000000000..cba77acfe4f991e0e324c1bda8e3b1f3e9cd5405
--- /dev/null
+++ b/reviewboard/hostingsvcs/utils/tests.py
@@ -0,0 +1,210 @@
+from __future__ import unicode_literals
+
+from django.utils.six.moves.urllib.parse import parse_qs, urlsplit
+
+from reviewboard.hostingsvcs.utils.paginator import (APIPaginator,
+                                                     InvalidPageError,
+                                                     ProxyPaginator)
+from reviewboard.testing import TestCase
+
+
+class DummyAPIPaginator(APIPaginator):
+    start_query_param = 'start'
+    per_page_query_param = 'per-page'
+
+    def fetch_url(self, url):
+        return {
+            'data': [1, 2, 3],
+            'headers': {},
+        }
+
+
+class APIPaginatorTests(TestCase):
+    """Tests for APIPaginator."""
+    def test_construct_initial_load(self):
+        """Testing APIPaginator construction performs initial load"""
+        paginator = DummyAPIPaginator(None, 'http://example.com', start=10)
+        self.assertEqual(paginator.page_data, [1, 2, 3])
+
+    def test_construct_with_start(self):
+        """Testing APIPaginator construction with start=<value>"""
+        url = 'http://example.com/api/list/?foo=1'
+        paginator = DummyAPIPaginator(None, url, start=10)
+
+        parts = urlsplit(paginator.url)
+        query_params = parse_qs(parts[3])
+
+        self.assertEqual(query_params['foo'], ['1'])
+        self.assertEqual(query_params['start'], ['10'])
+
+    def test_construct_with_per_page(self):
+        """Testing APIPaginator construction with per_page=<value>"""
+        url = 'http://example.com/api/list/?foo=1'
+        paginator = DummyAPIPaginator(None, url, per_page=10)
+
+        parts = urlsplit(paginator.url)
+        query_params = parse_qs(parts[3])
+
+        self.assertEqual(query_params['foo'], ['1'])
+        self.assertEqual(query_params['per-page'], ['10'])
+
+    def test_extract_page_info(self):
+        """Testing APIPaginator page information extraction"""
+        class PageInfoAPIPaginator(APIPaginator):
+            def fetch_url(self, url):
+                return {
+                    'data': ['a', 'b', 'c'],
+                    'headers': {
+                        'Foo': 'Bar',
+                    },
+                    'per_page': 10,
+                    'total_count': 100,
+                    'prev_url': 'http://example.com/?page=1',
+                    'next_url': 'http://example.com/?page=3',
+                }
+
+        paginator = PageInfoAPIPaginator(None, 'http://example.com/')
+
+        self.assertEqual(paginator.page_data, ['a', 'b', 'c'])
+        self.assertEqual(paginator.page_headers['Foo'], 'Bar')
+        self.assertEqual(paginator.per_page, 10)
+        self.assertEqual(paginator.total_count, 100)
+        self.assertEqual(paginator.prev_url, 'http://example.com/?page=1')
+        self.assertEqual(paginator.next_url, 'http://example.com/?page=3')
+
+    def test_prev(self):
+        """Testing APIPaginator.prev"""
+        prev_url = 'http://example.com/?page=1'
+
+        paginator = DummyAPIPaginator(None, 'http://example.com')
+        paginator.prev_url = prev_url
+
+        self.assertTrue(paginator.has_prev)
+        self.assertFalse(paginator.has_next)
+
+        data = paginator.prev()
+
+        self.assertEqual(data, [1, 2, 3])
+        self.assertEqual(paginator.url, prev_url)
+
+    def test_prev_without_prev_page(self):
+        """Testing APIPaginator.prev without a previous page"""
+        paginator = DummyAPIPaginator(None, 'http://example.com')
+        url = paginator.url
+
+        self.assertFalse(paginator.has_prev)
+        self.assertRaises(InvalidPageError, paginator.prev)
+        self.assertEqual(paginator.url, url)
+
+    def test_next(self):
+        """Testing APIPaginator.next"""
+        next_url = 'http://example.com/?page=3'
+
+        paginator = DummyAPIPaginator(None, 'http://example.com')
+        paginator.next_url = next_url
+
+        self.assertFalse(paginator.has_prev)
+        self.assertTrue(paginator.has_next)
+
+        data = paginator.next()
+
+        self.assertEqual(data, [1, 2, 3])
+        self.assertEqual(paginator.url, next_url)
+
+    def test_next_without_next_page(self):
+        """Testing APIPaginator.next without a next page"""
+        paginator = DummyAPIPaginator(None, 'http://example.com')
+        url = paginator.url
+
+        self.assertFalse(paginator.has_next)
+        self.assertRaises(InvalidPageError, paginator.next)
+        self.assertEqual(paginator.url, url)
+
+
+class ProxyPaginatorTests(TestCase):
+    """Tests for ProxyPaginator."""
+    def setUp(self):
+        self.paginator = DummyAPIPaginator(None, 'http://example.com')
+        self.proxy = ProxyPaginator(self.paginator)
+
+    def test_has_prev(self):
+        """Testing ProxyPaginator.has_prev"""
+        self.assertFalse(self.proxy.has_prev)
+
+        self.paginator.prev_url = 'http://example.com/?start=1'
+        self.assertTrue(self.proxy.has_prev)
+
+    def test_has_next(self):
+        """Testing ProxyPaginator.has_next"""
+        self.assertFalse(self.proxy.has_next)
+
+        self.paginator.next_url = 'http://example.com/?start=2'
+        self.assertTrue(self.proxy.has_next)
+
+    def test_per_page(self):
+        """Testing ProxyPaginator.per_page"""
+        self.paginator.per_page = 10
+        self.assertEqual(self.proxy.per_page, 10)
+
+    def test_total_count(self):
+        """Testing ProxyPaginator.total_count"""
+        self.paginator.total_count = 100
+        self.assertEqual(self.proxy.total_count, 100)
+
+    def test_prev(self):
+        """Testing ProxyPaginator.prev"""
+        prev_url = 'http://example.com/?page=1'
+
+        self.paginator.prev_url = prev_url
+
+        self.assertTrue(self.proxy.has_prev)
+        self.assertFalse(self.proxy.has_next)
+
+        data = self.proxy.prev()
+
+        self.assertEqual(data, [1, 2, 3])
+        self.assertEqual(self.paginator.url, prev_url)
+
+    def test_next(self):
+        """Testing ProxyPaginator.next"""
+        next_url = 'http://example.com/?page=3'
+
+        self.paginator.next_url = next_url
+
+        self.assertFalse(self.proxy.has_prev)
+        self.assertTrue(self.proxy.has_next)
+
+        data = self.proxy.next()
+
+        self.assertEqual(data, [1, 2, 3])
+        self.assertEqual(self.paginator.url, next_url)
+
+    def test_normalize_page_data(self):
+        """Testing ProxyPaginator.normalize_page_data"""
+        proxy = ProxyPaginator(
+            self.paginator,
+            normalize_page_data_func=lambda data: list(reversed(data)))
+
+        self.assertEqual(proxy.page_data, [3, 2, 1])
+
+    def test_normalize_page_data_on_prev(self):
+        """Testing ProxyPaginator.normalize_page_data on prev"""
+        proxy = ProxyPaginator(
+            self.paginator,
+            normalize_page_data_func=lambda data: list(reversed(data)))
+        self.paginator.prev_url = 'http://example.com/?page=1'
+
+        data = proxy.prev()
+
+        self.assertEqual(data, [3, 2, 1])
+
+    def test_normalize_page_data_on_next(self):
+        """Testing ProxyPaginator.normalize_page_data on next"""
+        proxy = ProxyPaginator(
+            self.paginator,
+            normalize_page_data_func=lambda data: list(reversed(data)))
+        self.paginator.next_url = 'http://example.com/?page=3'
+
+        data = proxy.next()
+
+        self.assertEqual(data, [3, 2, 1])
