diff --git a/docs/manual/extending/extensions/hooks/index.rst b/docs/manual/extending/extensions/hooks/index.rst
index ba2c918abaf0f7f9d4676c999674041fea2848a9..c9f845995e51156645752ae37d521c3a0a713b59 100644
--- a/docs/manual/extending/extensions/hooks/index.rst
+++ b/docs/manual/extending/extensions/hooks/index.rst
@@ -25,6 +25,7 @@ The following hooks are available for use by extensions.
    datagrid-columns-hook
    file-attachment-thumbnail-hook
    navigation-bar-hook
+   review-request-approval-hook
    review-request-fieldsets-hook
    review-request-fields-hook
    review-ui-hook
diff --git a/docs/manual/extending/extensions/hooks/review-request-approval-hook.rst b/docs/manual/extending/extensions/hooks/review-request-approval-hook.rst
new file mode 100644
index 0000000000000000000000000000000000000000..2805eaa4cf1b374ecce39ca6cb4f92cca19bd3ad
--- /dev/null
+++ b/docs/manual/extending/extensions/hooks/review-request-approval-hook.rst
@@ -0,0 +1,64 @@
+.. _review-request-approval-hook:
+
+=========================
+ReviewRequestApprovalHook
+=========================
+
+In Review Board 2.0, review requests have a concept of "approval." This is a
+flag exposed in the API on :ref:`webapi2.0-review-request-resource` that
+indicates if the change on the review request has met the necessary
+requirements to be committed to the codebase. Pre-commit hooks on the
+repository can use this to allow or prevent check-ins.
+
+.. note::
+
+   Note that this is purely for integration with extensions and consumers of
+   the API. Review Board does not use approval to enforce any actions itself.)
+
+By default, the flag is set if there's at least one Ship It! and no open
+issues, but custom logic can be provided by an extension.
+
+:py:class:`reviewboard.extensions.hooks.ReviewRequestApprovalHook` allows
+an extension to make a decision on whether a review request is approved.
+
+To use it, simply subclass and provide a custom :py:meth:`is_approved`
+function. This takes the review request, the previously calculated approved
+state, and the previously calculated approval failure string. (Both the
+previously calculated values may come from another
+:py:class:`ReviewRequestApprovalHook` or the initial approval checks.)
+
+Based on that information and its calculations, it can return the new
+approval state and optional failure reason, which will be reflected in the
+API.
+
+Most often, a hook will want to return ``False`` if the previous approved
+value is ``False``, and pass along the previous failure reason as well.
+
+
+Example
+=======
+
+.. code-block:: python
+
+    from reviewboard.extensions.base import Extension
+    from reviewboard.extensions.hooks import ReviewRequestApprovalHook
+
+
+    class SampleApprovalHook(ReviewRequestApprovalHook):
+        def is_approved(self, review_request, prev_approved):
+            # Require at least 2 Ship It!'s from everyone but Bob. Bob needs
+            # at least 3.
+            if not prev_approved:
+                return prev_approved, prev_failure
+            elif (review_request.submitter.username == 'bob' and
+                  review_request.shipit_count < 3):
+                return False, 'Bob, you need at least 3 "Ship It!\'s."'
+            elif review_request.shipit_count < 2:
+                return False, 'You need at least 2 "Ship It!\'s."'
+            else:
+                return True
+
+
+    class SampleExtension(Extension):
+        def initialize(self):
+            SampleApprovalHook(self)
diff --git a/reviewboard/extensions/hooks.py b/reviewboard/extensions/hooks.py
index 027f8ad37f518f41677ea8036cefda8586db377d..c7b35313b6005802329edd0a9f4f6ccc9b0f57c9 100644
--- a/reviewboard/extensions/hooks.py
+++ b/reviewboard/extensions/hooks.py
@@ -181,6 +181,34 @@ class NavigationBarHook(ExtensionHook):
 
 
 @six.add_metaclass(ExtensionHookPoint)
+class ReviewRequestApprovalHook(ExtensionHook):
+    """A hook for determining if a review request is approved.
+
+    Extensions can use this to hook into the process for determining
+    review request approval, which may impact any scripts integrating
+    with Review Board to, for example, allow committing to a repository.
+    """
+    def is_approved(self, review_request, prev_approved):
+        """Determines if the review request is approved.
+
+        This function is provided with the review request and the previously
+        calculated approved state (either from a prior hook, or from the
+        base state of ``ship_it_count > 0 and issue_open_count == 0``).
+
+        If approved, this should return True. If unapproved, it should
+        return a tuple with False and a string briefly explaining why it's
+        not approved. This may be displayed to the user.
+
+        It generally should also take the previous approved state into
+        consideration in this choice (such as returning False if the previous
+        state is False). This is, however, fully up to the hook.
+
+        The approval decision may be overridden by any following hooks.
+        """
+        raise NotImplementedError
+
+
+@six.add_metaclass(ExtensionHookPoint)
 class ReviewRequestFieldSetsHook(ExtensionHook):
     """A hook for creating fieldsets on the side of the review request page.
 
diff --git a/reviewboard/reviews/models/review_request.py b/reviewboard/reviews/models/review_request.py
index b4d773c44d93a4a4014301eb69418bf398611844..51f94c6a12be69cc0be40a5e7904b21c69312153 100644
--- a/reviewboard/reviews/models/review_request.py
+++ b/reviewboard/reviews/models/review_request.py
@@ -240,6 +240,36 @@ class ReviewRequest(BaseReviewRequestDetails):
 
     commit = property(get_commit, set_commit)
 
+    @property
+    def approved(self):
+        """Returns whether or not a review request is approved by reviewers.
+
+        On a default installation, a review request is approved if it has
+        at least one Ship It!, and doesn't have any open issues.
+
+        Extensions may customize approval by providing their own
+        ReviewRequestApprovalHook.
+        """
+        if not hasattr(self, '_approved'):
+            self._calculate_approval()
+
+        return self._approved
+
+    @property
+    def approval_failure(self):
+        """Returns the error indicating why a review request isn't approved.
+
+        If ``approved`` is ``False``, this will provide the text describing
+        why it wasn't approved.
+
+        Extensions may customize approval by providing their own
+        ReviewRequestApprovalHook.
+        """
+        if not hasattr(self, '_approval_failure'):
+            self._calculate_approval()
+
+        return self._approval_failure
+
     def get_participants(self):
         """Returns a list of users who have discussed this review request."""
         # See the comment in Review.get_participants for this list
@@ -739,6 +769,38 @@ class ReviewRequest(BaseReviewRequestDetails):
                         profile__starred_review_requests=self,
                         local_site=local_site))
 
+    def _calculate_approval(self):
+        """Calculates the approval information for the review request."""
+        from reviewboard.extensions.hooks import ReviewRequestApprovalHook
+
+        approved = True
+        failure = None
+
+        if self.shipit_count == 0:
+            approved = False
+            failure = 'The review request has not been marked "Ship It!"'
+        elif self.issue_open_count > 0:
+            approved = False
+            failure = 'The review request has open issues.'
+
+        for hook in ReviewRequestApprovalHook.hooks:
+            result = hook.is_approved(self, approved, failure)
+
+            if isinstance(result, tuple):
+                approved, failure = result
+            elif isinstance(result, bool):
+                approved = result
+            else:
+                raise ValueError('%r returned an invalid value %r from '
+                                 'is_approved'
+                                 % (hook, result))
+
+            if approved:
+                failure = None
+
+        self._approval_failure = failure
+        self._approved = approved
+
     def get_review_request(self):
         """Returns this review request.
 
diff --git a/reviewboard/webapi/resources/review_request.py b/reviewboard/webapi/resources/review_request.py
index edb66cea70a3d148f5d57c5cc67ade9260a90355..b15cf0f386665eea5839bc2edda9b2b6a49f4825 100644
--- a/reviewboard/webapi/resources/review_request.py
+++ b/reviewboard/webapi/resources/review_request.py
@@ -82,6 +82,23 @@ class ReviewRequestResource(MarkdownFieldsMixin, WebAPIResource):
             'type': int,
             'description': 'The numeric ID of the review request.',
         },
+        'approved': {
+            'type': bool,
+            'description': 'Whether the review request has been approved '
+                           'by reviewers.\n'
+                           '\n'
+                           'On a default install, a review request is '
+                           'approved if it has at least one Ship It! and '
+                           'open issues. Extensions may change these '
+                           'requirements.',
+            'added_in': '2.0',
+        },
+        'approval_failure': {
+            'type': six.text_type,
+            'description': 'The reason why the review request was not '
+                           'approved. This will be ``null`` if approved.',
+            'added_in': '2.0',
+        },
         'blocks': {
             'type': ['reviewboard.webapi.resources.review_request.'
                      'ReviewRequestResource'],
