diff --git a/reviewboard/settings.py b/reviewboard/settings.py
index 3009d41126c01a035d9393701a30e859ccbcef04..6f449d15fb9b5e2d6d17b89c449d0613181728bc 100644
--- a/reviewboard/settings.py
+++ b/reviewboard/settings.py
@@ -292,6 +292,7 @@ PIPELINE_JS = {
             'rb/js/models/tests/diffCommentModelTests.js',
             'rb/js/models/tests/diffReviewableModelTests.js',
             'rb/js/models/tests/draftReviewModelTests.js',
+            'rb/js/models/tests/draftReviewRequestModelTests.js',
             'rb/js/models/tests/fileAttachmentModelTests.js',
             'rb/js/models/tests/fileAttachmentCommentModelTests.js',
             'rb/js/models/tests/screenshotModelTests.js',
@@ -318,6 +319,7 @@ PIPELINE_JS = {
             'rb/js/datastore.js',
             'rb/js/models/baseResourceModel.js',
             'rb/js/models/draftResourceModelMixin.js',
+            'rb/js/models/draftReviewRequestModel.js',
             'rb/js/models/reviewModel.js',
             'rb/js/models/draftReviewModel.js',
             'rb/js/models/baseCommentModel.js',
@@ -329,6 +331,7 @@ PIPELINE_JS = {
             'rb/js/models/fileAttachmentCommentModel.js',
             'rb/js/models/fileAttachmentCommentReplyModel.js',
             'rb/js/models/reviewGroupModel.js',
+            'rb/js/models/reviewRequestModel.js',
             'rb/js/models/screenshotModel.js',
             'rb/js/models/screenshotCommentModel.js',
             'rb/js/models/screenshotCommentReplyModel.js',
diff --git a/reviewboard/static/rb/js/common.js b/reviewboard/static/rb/js/common.js
index 977597ea0c14df07164746b0f9d6cbe525b3bd90..b515306126b2d5a228612dc6079fcf8a5981443c 100644
--- a/reviewboard/static/rb/js/common.js
+++ b/reviewboard/static/rb/js/common.js
@@ -226,7 +226,9 @@ function registerToggleStar() {
             var objid = self.attr("data-object-id");
 
             if (type == "reviewrequests") {
-                obj = new RB.ReviewRequest(objid);
+                obj = new RB.ReviewRequest({
+                    id: objid
+                });
             } else if (type == "groups") {
                 obj = new RB.ReviewGroup({
                     id: objid
diff --git a/reviewboard/static/rb/js/datastore.js b/reviewboard/static/rb/js/datastore.js
index fedc0ff49c1fba7ca333e4037f0d7c84e706d509..b5be8446341cc60320f25136fa0cff20064a8fb1 100644
--- a/reviewboard/static/rb/js/datastore.js
+++ b/reviewboard/static/rb/js/datastore.js
@@ -1,261 +1,3 @@
-RB.ReviewRequest = function(id, prefix, path) {
-    this.id = id;
-    this.prefix = prefix;
-    this.path = path;
-    this.reviews = {};
-    this.draft_review = null;
-    this.links = {};
-    this.loaded = false;
-
-    return this;
-};
-
-$.extend(RB.ReviewRequest, {
-    /* Constants */
-    CHECK_UPDATES_MSECS: 5 * 60 * 1000, // Every 5 minutes
-    CLOSE_DISCARDED: 1,
-    CLOSE_SUBMITTED: 2
-});
-
-$.extend(RB.ReviewRequest.prototype, {
-    /* Review request API */
-    createDiff: function(revision, interdiff_revision) {
-        return new RB.Diff({
-            parentObject: this
-        });
-    },
-
-    createReview: function(review_id) {
-        if (review_id == undefined) {
-            if (this.draft_review == null) {
-                this.draft_review = new RB.DraftReview({
-                    parentObject: this
-                });
-            }
-
-            return this.draft_review;
-        } else if (!this.reviews[review_id]) {
-            this.reviews[review_id] = new RB.Review({
-                parentObject: this,
-                id: review_id
-            });
-        }
-
-        return this.reviews[review_id];
-    },
-
-    createScreenshot: function(screenshot_id) {
-        return new RB.Screenshot({
-            parentObject: this,
-            id: screenshot_id
-        });
-    },
-
-    createFileAttachment: function(file_attachment_id) {
-        return new RB.FileAttachment({
-            parentObject: this,
-            id: file_attachment_id
-        });
-    },
-
-    /*
-     * Ensures that the review request's state is loaded.
-     *
-     * If it's not loaded, then a request will be made to load the state
-     * before the callback is called.
-     */
-    ready: function(on_ready) {
-        if (this.loaded) {
-            on_ready.apply(this, arguments);
-        } else {
-            var self = this;
-
-            this._apiCall({
-                type: "GET",
-                path: "/",
-                success: function(rsp) {
-                    self.loaded = true;
-                    self.links = rsp.review_request.links;
-                    on_ready.apply(this, arguments);
-                }
-            });
-        }
-    },
-
-    // XXX Needed until we move this to Backbone.js.
-    ensureCreated: function(cb) {
-        this.ready(cb);
-    },
-
-    setDraftField: function(options) {
-        data = {};
-        data[options.field] = options.value;
-
-        if (options.field == "target_people" ||
-            options.field == "target_groups") {
-            data.expand = options.field;
-        }
-
-        this._apiCall({
-            type: "PUT",
-            path: "/draft/",
-            buttons: options.buttons,
-            data: data,
-            success: options.success // XXX
-        });
-    },
-
-    /*
-     * Marks a review request as starred or unstarred.
-     */
-    setStarred: function(starred, options, context) {
-        var watched = RB.UserSession.instance.watchedReviewRequests;
-
-        if (starred) {
-            watched.addImmediately(this, options, context);
-        } else {
-            watched.removeImmediately(this, options, context);
-        }
-    },
-
-    publish: function(options) {
-        var self = this;
-
-        options = $.extend(true, {}, options);
-
-        self.ready(function() {
-            self._apiCall({
-                type: "PUT",
-                url: self.links.draft.href,
-                data: {
-                    public: 1
-                },
-                buttons: options.buttons
-            });
-        });
-    },
-
-    discardDraft: function(options) {
-        var self = this;
-
-        self.ready(function() {
-            self._apiCall({
-                type: "DELETE",
-                url: self.links.draft.href,
-                buttons: options.buttons
-            });
-        });
-    },
-
-    close: function(options) {
-        var self = this;
-        var statusType;
-
-        if (options.type == RB.ReviewRequest.CLOSE_DISCARDED) {
-            statusType = "discarded";
-        } else if (options.type == RB.ReviewRequest.CLOSE_SUBMITTED) {
-            statusType = "submitted";
-        } else {
-            return;
-        }
-
-        data = {
-            status: statusType
-        };
-
-        if (options.description !== undefined) {
-            data.description = options.description;
-        }
-
-        self.ready(function() {
-            self._apiCall({
-                type: "PUT",
-                path: "/",
-                data: data,
-                buttons: options.buttons
-            });
-        });
-    },
-
-    reopen: function(options) {
-        options = $.extend(true, {}, options);
-
-        this._apiCall({
-            type: "PUT",
-            path: "/",
-            data: {
-                status: "pending"
-            },
-            buttons: options.buttons
-        });
-    },
-
-    deletePermanently: function(options) {
-        options = $.extend(true, {}, options);
-
-        this._apiCall({
-            type: "DELETE",
-            path: "/",
-            buttons: options.buttons,
-            success: options.success
-        });
-    },
-
-    markUpdated: function(timestamp) {
-        this.lastUpdateTimestamp = timestamp;
-    },
-
-    beginCheckForUpdates: function(type, lastUpdateTimestamp) {
-        var self = this;
-
-        this.checkUpdatesType = type;
-        this.lastUpdateTimestamp = lastUpdateTimestamp;
-
-        setTimeout(function() { self._checkForUpdates(); },
-                   RB.ReviewRequest.CHECK_UPDATES_MSECS);
-    },
-
-    _checkForUpdates: function() {
-        var self = this;
-
-        self.ready(function() {
-            self._apiCall({
-                type: "GET",
-                noActivityIndicator: true,
-                url: self.links.last_update.href,
-                success: function(rsp) {
-                    var last_update = rsp.last_update;
-
-                    if ((self.checkUpdatesType == undefined ||
-                         self.checkUpdatesType == last_update.type) &&
-                        self.lastUpdateTimestamp != last_update.timestamp) {
-                        $.event.trigger("updated", [last_update], self);
-                    }
-
-                    self.lastUpdateTimestamp = last_update.timestamp;
-
-                    setTimeout(function() { self._checkForUpdates(); },
-                               RB.ReviewRequest.CHECK_UPDATES_MSECS);
-                }
-            });
-        });
-    },
-
-    _apiCall: function(options) {
-        var self = this;
-
-        options.prefix = this.prefix;
-        options.path = "/review-requests/" + this.id + options.path;
-
-        if (!options.success) {
-            options.success = function() { window.location = self.path; };
-        }
-
-        RB.apiCall(options);
-    }
-});
-
-
 /*
  * Convenience wrapper for Review Board API functions. This will handle
  * any button disabling/enabling, write to the correct path prefix, form
diff --git a/reviewboard/static/rb/js/models/baseResourceModel.js b/reviewboard/static/rb/js/models/baseResourceModel.js
index 5c5270a750c46b0f50a6aba7238a27cc5dd16382..4dcd3f3a3d273633b70b8f8f39a93d0ac0e35862 100644
--- a/reviewboard/static/rb/js/models/baseResourceModel.js
+++ b/reviewboard/static/rb/js/models/baseResourceModel.js
@@ -47,16 +47,7 @@ RB.BaseResource = Backbone.Model.extend({
             parentObject = this.get('parentObject');
 
             if (parentObject) {
-                /*
-                 * XXX This is temporary to support older-style resource
-                 *     objects. We should just use get() once we're moved
-                 *     entirely onto BaseResource.
-                 */
-                if (parentObject.cid) {
-                    links = parentObject.get('links');
-                } else {
-                    links = parentObject.links;
-                }
+                links = parentObject.get('links');
 
                 if (links) {
                     key = _.result(this, 'listKey');
@@ -114,14 +105,10 @@ RB.BaseResource = Backbone.Model.extend({
              * the server, but we still need to ensure that the parent is loaded
              * in order for it to have valid links.
              */
-            if (parentObject.cid) {
-                parentObject.ready({
-                    ready: success,
-                    error: error
-                });
-            } else {
-                parentObject.ready(success || function() {});
-            }
+            parentObject.ready({
+                ready: success,
+                error: error
+            });
         } else if (options.ready) {
             // Fallback for dummy objects
             options.ready.call(context);
@@ -200,14 +187,10 @@ RB.BaseResource = Backbone.Model.extend({
                              _.bindCallbacks(options, context));
 
         if (parentObject) {
-            if (parentObject.cid) {
-                parentObject.ready({
-                    ready: fetchObject,
-                    error: options.error
-                }, this);
-            } else {
-                parentObject.ready(fetchObject);
-            }
+            parentObject.ready({
+                ready: fetchObject,
+                error: options.error
+            }, this);
         } else {
             fetchObject();
         }
@@ -248,14 +231,10 @@ RB.BaseResource = Backbone.Model.extend({
                     saveObject = _.bind(this._saveObject, this, options,
                                         context);
 
-                    if (parentObject.cid) {
-                        parentObject.ensureCreated({
-                            success: saveObject,
-                            error: options.error
-                        }, this);
-                    } else {
-                        parentObject.ensureCreated(saveObject);
-                    }
+                    parentObject.ensureCreated({
+                        success: saveObject,
+                        error: options.error
+                    }, this);
                 } else {
                     this._saveObject(options, context);
                 }
@@ -402,13 +381,9 @@ RB.BaseResource = Backbone.Model.extend({
              *     objects. We should just use ready() once we're moved
              *     entirely onto BaseResource.
              */
-            if (parentObject.cid) {
-                parentObject.ready(_.defaults({
-                    ready: destroyObject
-                }, _.bindCallbacks(options, context)));
-            } else {
-                parentObject.ready(destroyObject);
-            }
+            parentObject.ready(_.defaults({
+                ready: destroyObject
+            }, _.bindCallbacks(options, context)));
         } else {
             destroyObject();
         }
@@ -528,7 +503,8 @@ RB.BaseResource = Backbone.Model.extend({
      */
     sync: function(method, model, options) {
         var data,
-            contentType;
+            contentType,
+            syncOptions;
 
         options = options || {};
 
@@ -540,29 +516,41 @@ RB.BaseResource = Backbone.Model.extend({
             contentType = 'application/x-www-form-urlencoded';
         }
 
-        return Backbone.sync.call(this, method, model, _.defaults({}, options, {
+        syncOptions = _.defaults({}, options, {
             /* Use form data instead of a JSON payload. */
             contentType: contentType,
             data: data,
-            processData: true,
-
-            error: function(model, xhr) {
-                var rsp = null,
-                    text;
-
-                if (_.isFunction(options.error)) {
-                    try {
-                        rsp = $.parseJSON(xhr.responseText);
-                        text = rsp.err.msg;
-                    } catch (e) {
-                        text = 'HTTP ' + xhr.status + ' ' + xhr.statusText;
-                    }
+            processData: true
+        });
+
+        syncOptions.error = _.bind(function(model, xhr) {
+            var rsp = null,
+                text;
+
+            try {
+                rsp = $.parseJSON(xhr.responseText);
+                text = rsp.err.msg;
+            } catch (e) {
+                text = 'HTTP ' + xhr.status + ' ' + xhr.statusText;
+            }
 
-                    xhr.errorText = text;
-                    options.error(model, xhr, options);
-                }
+            if (rsp && _.has(rsp, this.rspNamespace)) {
+                /*
+                 * The response contains the current version of the object,
+                 * which we want to preserve, in case it did any partial
+                 * updating of data.
+                 */
+                this.set(this.parse(rsp));
             }
-        }));
+
+            if (_.isFunction(options.error)) {
+                xhr.errorText = text;
+                xhr.errorPayload = rsp;
+                options.error(model, xhr, options);
+            }
+        }, this);
+
+        return Backbone.sync.call(this, method, model, syncOptions);
     }
 }, {
     strings: {
diff --git a/reviewboard/static/rb/js/models/draftResourceModelMixin.js b/reviewboard/static/rb/js/models/draftResourceModelMixin.js
index 80fd801c948a561ca2bb41c8f9e33ab93728385f..2268cbe392b856f0c72d63c0880b4bed93037791 100644
--- a/reviewboard/static/rb/js/models/draftResourceModelMixin.js
+++ b/reviewboard/static/rb/js/models/draftResourceModelMixin.js
@@ -82,13 +82,7 @@ RB.DraftResourceModelMixin = {
         if (this._needDraft) {
             parentObject = this.get('parentObject');
             linkName = _.result(this, 'listKey');
-
-            /* XXX Work with old-style and new-style resources' links. */
-            if (parentObject.cid) {
-                links = parentObject.get('links');
-            } else {
-                links = parentObject.links;
-            }
+            links = parentObject.get('links');
 
             /*
              * Chrome hyper-aggressively caches things it shouldn't, and
diff --git a/reviewboard/static/rb/js/models/draftReviewRequestModel.js b/reviewboard/static/rb/js/models/draftReviewRequestModel.js
new file mode 100644
index 0000000000000000000000000000000000000000..bb697f028492c0ed5e5e51fdac35aa880bf8131f
--- /dev/null
+++ b/reviewboard/static/rb/js/models/draftReviewRequestModel.js
@@ -0,0 +1,71 @@
+/*
+ * The draft of a review request.
+ *
+ * This provides editing capabilities for a review request draft, as well
+ * as the ability to publish and discard (destroy) a draft.
+ */
+RB.DraftReviewRequest = RB.BaseResource.extend({
+    defaults: _.defaults({
+        branch: null,
+        bugsClosed: null,
+        changeDescription: null,
+        description: null,
+        public: null,
+        summary: null,
+        targetGroups: null,
+        targetPeople: null,
+        testingDone: null
+    }, RB.BaseResource.prototype.defaults),
+
+    rspNamespace: 'draft',
+    listKey: 'draft',
+
+    expandedFields: ['target_people', 'target_groups'],
+
+    url: function() {
+        return this.get('parentObject').get('links').draft.href;
+    },
+
+    /*
+     * Publishes the draft.
+     */
+    publish: function(options, context) {
+        this.save(
+            _.defaults({
+                data: {
+                    public: 1
+                }
+            }, options),
+            context);
+    },
+
+    parse: function(rsp) {
+        var result = RB.BaseResource.prototype.parse.call(this, rsp),
+            rspData = rsp[this.rspNamespace];
+
+        result.branch = rspData.branch;
+        result.bugsClosed = rspData.bugs_closed;
+        result.changeDescription = rspData.change_description;
+        result.description = rspData.description;
+        result.public = rspData.public;
+        result.summary = rspData.summary;
+        result.targetGroups = rspData.target_groups;
+        result.targetPeople = rspData.target_people;
+        result.testingDone = rspData.testing_done;
+
+        return result;
+    },
+
+    sync: function(method, model, options) {
+        /*
+         * Expand certain fields so that we can be sure that the values
+         * will be what we expect whether we're updating or getting.
+         */
+        options.data = _.defaults({
+            expand: this.expandedFields.join(',')
+        }, options.data);
+
+        return RB.BaseResource.prototype.sync.call(this, method, model,
+                                                   options);
+    }
+});
diff --git a/reviewboard/static/rb/js/models/reviewRequestModel.js b/reviewboard/static/rb/js/models/reviewRequestModel.js
new file mode 100644
index 0000000000000000000000000000000000000000..d7380b2f57a60d257b9104d7e926839b338187a4
--- /dev/null
+++ b/reviewboard/static/rb/js/models/reviewRequestModel.js
@@ -0,0 +1,245 @@
+/*
+ * A review request.
+ *
+ * ReviewRequest is the starting point for much of the resource API. Through
+ * it, the caller can create drafts, diffs, file attachments, and screenshots.
+ *
+ * Fields on a ReviewRequest are set by accessing the ReviewRequest.draft
+ * object. Through there, fields can be set like any other model and then
+ * saved.
+ *
+ * A review request can be closed by using the close() function, reopened
+ * through reopen(), or even permanently destroyed by calling destroy().
+ */
+RB.ReviewRequest = RB.BaseResource.extend({
+    defaults: _.defaults({
+        draftReview: null,
+        localSitePrefix: null
+    }),
+
+    rspNamespace: 'review_request',
+
+    initialize: function() {
+        RB.BaseResource.prototype.initialize.apply(this, arguments);
+
+        this.reviews = new Backbone.Collection([], {
+            model: RB.Review
+        });
+
+        this.draft = new RB.DraftReviewRequest({
+            parentObject: this
+        });
+    },
+
+    url: function() {
+        var url = SITE_ROOT + (this.get('localSitePrefix') || '') +
+                  'api/review-requests/';
+
+        if (!this.isNew()) {
+            url += this.id;
+        }
+
+        return url + '/';
+    },
+
+    /*
+     * Creates a Diff object for this review request.
+     */
+    createDiff: function() {
+        return new RB.Diff({
+            parentObject: this
+        });
+    },
+
+    /*
+     * Creates a Review object for this review request.
+     *
+     * If an ID is specified, the Review object will reference that ID.
+     * Otherwise, it is considered a draft review, and will either return
+     * the existing one (if the draftReview attribute is set), or create
+     * a new one (and set the attribute).
+     */
+    createReview: function(reviewID) {
+        var review;
+
+        if (reviewID === undefined) {
+            review = this.get('draftReview');
+
+            if (review === null) {
+                review = new RB.DraftReview({
+                    parentObject: this
+                });
+
+                this.set('draftReview', review);
+            }
+
+            return review;
+        } else {
+            review = this.reviews.get(reviewID);
+
+            if (!review) {
+                review = new RB.Review({
+                    parentObject: this,
+                    id: reviewID
+                });
+                this.reviews.add(review);
+            }
+        }
+
+        return review;
+    },
+
+    /*
+     * Creates a Screenshot object for this review request.
+     */
+    createScreenshot: function(screenshotID) {
+        return new RB.Screenshot({
+            parentObject: this,
+            id: screenshotID
+        });
+    },
+
+    /*
+     * Creates a FileAttachment object for this review request.
+     */
+    createFileAttachment: function(fileAttachmentID) {
+        return new RB.FileAttachment({
+            parentObject: this,
+            id: fileAttachmentID
+        });
+    },
+
+    /*
+     * Marks a review request as starred or unstarred.
+     */
+    setStarred: function(starred, options, context) {
+        var watched = RB.UserSession.instance.watchedReviewRequests;
+
+        if (starred) {
+            watched.addImmediately(this, options, context);
+        } else {
+            watched.removeImmediately(this, options, context);
+        }
+    },
+
+    /*
+     * Closes the review request.
+     *
+     * A 'type' option must be provided, which must match one of the
+     * close types (ReviewRequest.CLOSE_DISCARDED or
+     * ReviewRequest.CLOSE_SUBMITTED).
+     *
+     * An optional description can be set by passing a 'description' option.
+     */
+    close: function(options, context) {
+        var data = {};
+
+        console.assert(options);
+
+        if (options.type === RB.ReviewRequest.CLOSE_DISCARDED) {
+            data.status = 'discarded';
+        } else if (options.type === RB.ReviewRequest.CLOSE_SUBMITTED) {
+            data.status = 'submitted';
+        } else {
+            if (_.isFunction(options.error)) {
+                options.error.call(this, {
+                    errorText: 'Invalid close type'
+                });
+            }
+
+            return;
+        }
+
+        if (options.description !== undefined) {
+            data.description = options.description;
+        }
+
+        options = _.defaults({
+            data: data
+        }, options);
+
+        delete options.type;
+        delete options.description;
+
+        this.save(options, context);
+    },
+
+    /*
+     * Reopens the review request.
+     */
+    reopen: function(options, context) {
+        this.save(
+            _.defaults({
+                data: {
+                    status: 'pending'
+                }
+            }, options),
+            context);
+    },
+
+    /*
+     * Marks the review request as having been updated at the given timestamp.
+     *
+     * This should be used when an action will trigger an update to the
+     * review request's Last Updated timestamp, but where we don't want
+     * a notification later on. The local copy of the timestamp can be
+     * bumped to mark it as up-to-date.
+     */
+    markUpdated: function(timestamp) {
+        this._lastUpdateTimestamp = timestamp;
+    },
+
+    /*
+     * Begins checking for server-side updates to the review request.
+     *
+     * This takes a type of update to check for, and the last known
+     * updated timestamp.
+     *
+     * The 'updated' event will be triggered when there's a new update.
+     */
+    beginCheckForUpdates: function(type, lastUpdateTimestamp) {
+        this._checkUpdatesType = type;
+        this._lastUpdateTimestamp = lastUpdateTimestamp;
+
+        setTimeout(_.bind(this._checkForUpdates, this),
+                   RB.ReviewRequest.CHECK_UPDATES_MSECS);
+    },
+
+    /*
+     * Checks for updates.
+     *
+     * This is called periodically after an initial call to
+     * beginCheckForUpdates. It will see if there's a new update yet on the
+     * server, and if there is, trigger the 'updated' event.
+     */
+    _checkForUpdates: function() {
+        this.ready({
+            ready: function() {
+                RB.apiCall({
+                    type: 'GET',
+                    prefix: this.get('sitePrefix'),
+                    noActivityIndicator: true,
+                    url: this.get('links').last_update.href,
+                    success: _.bind(function(rsp) {
+                        var lastUpdate = rsp.last_update;
+                        if ((this._checkUpdatesType === undefined ||
+                             this._checkUpdatesType === lastUpdate.type) &&
+                            this._lastUpdateTimestamp !== lastUpdate.timestamp) {
+                            this.trigger('updated', lastUpdate);
+                        }
+
+                        this._lastUpdateTimestamp = lastUpdate.timestamp;
+
+                        setTimeout(_.bind(this._checkForUpdates, this),
+                                   RB.ReviewRequest.CHECK_UPDATES_MSECS);
+                    }, this)
+                });
+            }
+        }, this);
+    }
+}, {
+    CHECK_UPDATES_MSECS: 5 * 60 * 1000, // Every 5 minutes
+
+    CLOSE_DISCARDED: 1,
+    CLOSE_SUBMITTED: 2
+});
diff --git a/reviewboard/static/rb/js/models/tests/draftReviewRequestModelTests.js b/reviewboard/static/rb/js/models/tests/draftReviewRequestModelTests.js
new file mode 100644
index 0000000000000000000000000000000000000000..5ae86efa19dbd439a92681a67c9c0815e879178a
--- /dev/null
+++ b/reviewboard/static/rb/js/models/tests/draftReviewRequestModelTests.js
@@ -0,0 +1,97 @@
+describe('models/DraftReviewRequest', function() {
+    var draft,
+        callbacks;
+
+    beforeEach(function() {
+        var reviewRequest = new RB.ReviewRequest({
+            id: 1,
+            links: {
+                draft: {
+                    href: '/api/review-requests/123/draft/'
+                }
+            }
+        });
+
+        draft = reviewRequest.draft;
+
+        callbacks = {
+            success: function() {},
+            error: function() {}
+        };
+
+        spyOn(callbacks, 'success');
+        spyOn(callbacks, 'error');
+
+        spyOn(reviewRequest, 'ready').andCallFake(function(options, context) {
+            options.ready.call(context);
+        });
+
+        spyOn(reviewRequest, 'ensureCreated')
+            .andCallFake(function(options, context) {
+                options.success.call(context);
+            });
+
+        spyOn(draft, 'ready').andCallFake(function(options, context) {
+            options.ready.call(context);
+        });
+    });
+
+    it('url', function() {
+        expect(draft.url()).toBe('/api/review-requests/123/draft/');
+    });
+
+    it('publish', function() {
+        spyOn(RB, 'apiCall').andCallThrough();
+        spyOn($, 'ajax').andCallFake(function(request) {
+            expect(request.data.public).toBe(1);
+
+            request.success({
+                stat: 'ok',
+                draft: {
+                    id: 1,
+                    links: {}
+                }
+            });
+        });
+
+        draft.publish({
+            success: callbacks.success,
+            error: callbacks.error
+        });
+
+        expect(RB.apiCall).toHaveBeenCalled();
+        expect($.ajax).toHaveBeenCalled();
+        expect(callbacks.success).toHaveBeenCalled();
+        expect(callbacks.error).not.toHaveBeenCalled();
+    });
+
+    it('parse', function() {
+        var data = draft.parse({
+            draft: {
+                id: 1,
+                branch: 'branch',
+                bugs_closed: 'bugsClosed',
+                change_description: 'changeDescription',
+                description: 'description',
+                public: 'public',
+                summary: 'summary',
+                target_groups: 'targetGroups',
+                target_people: 'targetPeople',
+                testing_done: 'testingDone'
+            }
+        });
+
+        expect(data).not.toBe(undefined);
+        expect(data.id).toBe(1);
+        expect(data.branch).toBe('branch');
+        expect(data.bugsClosed).toBe('bugsClosed');
+        expect(data.changeDescription).toBe('changeDescription');
+        expect(data.description).toBe('description');
+        expect(data.public).toBe('public');
+        expect(data.summary).toBe('summary');
+        expect(data.targetGroups).toBe('targetGroups');
+        expect(data.targetPeople).toBe('targetPeople');
+        expect(data.testingDone).toBe('testingDone');
+    });
+});
+
diff --git a/reviewboard/static/rb/js/models/tests/reviewRequestModelTests.js b/reviewboard/static/rb/js/models/tests/reviewRequestModelTests.js
index 0141ee7344a3f2b7e866ebf3220a341a8b5e49d1..95ad0418bc9ad510b538b477aca3ff6f0c5e4e30 100644
--- a/reviewboard/static/rb/js/models/tests/reviewRequestModelTests.js
+++ b/reviewboard/static/rb/js/models/tests/reviewRequestModelTests.js
@@ -1,9 +1,97 @@
 describe('models/ReviewRequest', function() {
+    var reviewRequest,
+        callbacks;
+
+    beforeEach(function() {
+        reviewRequest = new RB.ReviewRequest({
+            id: 1
+        });
+
+        callbacks = {
+            success: function() {},
+            error: function() {}
+        };
+
+        spyOn(callbacks, 'success');
+        spyOn(callbacks, 'error');
+
+        spyOn(reviewRequest, 'ready').andCallFake(function(options, context) {
+            options.ready.call(context);
+        });
+    });
+
+    it('createDiff', function() {
+        var diff = reviewRequest.createDiff();
+
+        expect(diff.get('parentObject')).toBe(reviewRequest);
+    });
+
+    it('createScreenshot', function() {
+        var screenshot = reviewRequest.createScreenshot(42);
+
+        expect(screenshot.get('parentObject')).toBe(reviewRequest);
+        expect(screenshot.id).toBe(42);
+    });
+
+    it('createFileAttachment', function() {
+        var fileAttachment = reviewRequest.createFileAttachment(42);
+
+        expect(fileAttachment.get('parentObject')).toBe(reviewRequest);
+        expect(fileAttachment.id).toBe(42);
+    });
+
+    it('reopen', function() {
+        spyOn(RB, 'apiCall').andCallThrough();
+        spyOn($, 'ajax').andCallFake(function(request) {
+            expect(request.type).toBe('PUT');
+            expect(request.data.status).toBe('pending');
+
+            request.success({
+                stat: 'ok',
+                review_request: {
+                    id: 1,
+                    links: {}
+                }
+            });
+        });
+
+        reviewRequest.reopen({
+            success: callbacks.success,
+            error: callbacks.error
+        });
+
+        expect(RB.apiCall).toHaveBeenCalled();
+        expect($.ajax).toHaveBeenCalled();
+        expect(callbacks.success).toHaveBeenCalled();
+        expect(callbacks.error).not.toHaveBeenCalled();
+    });
+
+    describe('createReview', function() {
+        it('With review ID', function() {
+            var review = reviewRequest.createReview(42);
+
+            expect(review.get('parentObject')).toBe(reviewRequest);
+            expect(review.get('id')).toBe(42);
+            expect(reviewRequest.get('draftReview')).toBe(null);
+            expect(reviewRequest.reviews.length).toBe(1);
+            expect(reviewRequest.reviews.get(42)).toBe(review);
+        });
+
+        it('Without review ID', function() {
+            var review1 = reviewRequest.createReview(),
+                review2 = reviewRequest.createReview();
+
+            expect(review1.get('parentObject')).toBe(reviewRequest);
+            expect(review1.id).toBeFalsy();
+            expect(reviewRequest.get('draftReview')).toBe(review1);
+            expect(review1).toBe(review2);
+            expect(reviewRequest.reviews.length).toBe(0);
+        });
+    });
+
     describe('setStarred', function() {
         var url = '/api/users/testuser/watched/review-requests/',
-            callbacks,
-            session,
-            reviewRequest;
+            session;
 
         beforeEach(function() {
             RB.UserSession.instance = null;
@@ -12,20 +100,11 @@ describe('models/ReviewRequest', function() {
                 watchedReviewRequestsURL: url
             });
 
-            reviewRequest = new RB.ReviewRequest(1);
-
-            callbacks = {
-                success: function() {},
-                error: function() {}
-            };
-
             spyOn(session.watchedReviewRequests, 'addImmediately')
                 .andCallThrough();
             spyOn(session.watchedReviewRequests, 'removeImmediately')
                 .andCallThrough();
             spyOn(RB, 'apiCall').andCallThrough();
-            spyOn(callbacks, 'success');
-            spyOn(callbacks, 'error');
         });
 
         it('true', function() {
@@ -68,4 +147,107 @@ describe('models/ReviewRequest', function() {
             expect(callbacks.error).not.toHaveBeenCalled();
         });
     });
+
+    describe('close', function() {
+        it('With type=CLOSE_DISCARDED', function() {
+            spyOn(RB, 'apiCall').andCallThrough();
+            spyOn($, 'ajax').andCallFake(function(request) {
+                expect(request.type).toBe('PUT');
+                expect(request.data.status).toBe('discarded');
+                expect(request.data.description).toBe(undefined);
+
+                request.success({
+                    stat: 'ok',
+                    review_request: {
+                        id: 1,
+                        links: {}
+                    }
+                });
+            });
+
+            reviewRequest.close({
+                type: RB.ReviewRequest.CLOSE_DISCARDED,
+                success: callbacks.success,
+                error: callbacks.error
+            });
+
+            expect(RB.apiCall).toHaveBeenCalled();
+            expect($.ajax).toHaveBeenCalled();
+            expect(callbacks.success).toHaveBeenCalled();
+            expect(callbacks.error).not.toHaveBeenCalled();
+        });
+
+        it('With type=CLOSE_SUBMITTED', function() {
+            spyOn(RB, 'apiCall').andCallThrough();
+            spyOn($, 'ajax').andCallFake(function(request) {
+                expect(request.type).toBe('PUT');
+                expect(request.data.status).toBe('submitted');
+                expect(request.data.description).toBe(undefined);
+
+                request.success({
+                    stat: 'ok',
+                    review_request: {
+                        id: 1,
+                        links: {}
+                    }
+                });
+            });
+
+            reviewRequest.close({
+                type: RB.ReviewRequest.CLOSE_SUBMITTED,
+                success: callbacks.success,
+                error: callbacks.error
+            });
+
+            expect(RB.apiCall).toHaveBeenCalled();
+            expect($.ajax).toHaveBeenCalled();
+            expect(callbacks.success).toHaveBeenCalled();
+            expect(callbacks.error).not.toHaveBeenCalled();
+        });
+
+        it('With invalid type', function() {
+            spyOn(RB, 'apiCall').andCallThrough();
+            spyOn($, 'ajax');
+
+            reviewRequest.close({
+                type: 'foo',
+                success: callbacks.success,
+                error: callbacks.error
+            });
+
+            expect(RB.apiCall).not.toHaveBeenCalled();
+            expect($.ajax).not.toHaveBeenCalled();
+            expect(callbacks.success).not.toHaveBeenCalled();
+            expect(callbacks.error).toHaveBeenCalled();
+        });
+
+        it('With description', function() {
+            spyOn(RB, 'apiCall').andCallThrough();
+            spyOn($, 'ajax').andCallFake(function(request) {
+                expect(request.type).toBe('PUT');
+                expect(request.data.status).toBe('submitted');
+                expect(request.data.description).toBe('test');
+
+                request.success({
+                    stat: 'ok',
+                    review_request: {
+                        id: 1,
+                        links: {}
+                    }
+                });
+            });
+
+            reviewRequest.close({
+                type: RB.ReviewRequest.CLOSE_SUBMITTED,
+                description: 'test',
+                success: callbacks.success,
+                error: callbacks.error
+            });
+
+            expect(RB.apiCall).toHaveBeenCalled();
+            expect($.ajax).toHaveBeenCalled();
+            expect(callbacks.success).toHaveBeenCalled();
+            expect(callbacks.error).not.toHaveBeenCalled();
+        });
+    });
 });
diff --git a/reviewboard/static/rb/js/reviews.js b/reviewboard/static/rb/js/reviews.js
index 9e2a61b76856c553d2f5f01bd8c5064d822e2d00..ed719573ae8fdd051efd32b261043f9a83f00035 100644
--- a/reviewboard/static/rb/js/reviews.js
+++ b/reviewboard/static/rb/js/reviews.js
@@ -4,9 +4,10 @@ var CommentReplyClasses = {
     file_attachment_comments: RB.FileAttachmentCommentReply
 };
 
-this.gReviewRequest = new RB.ReviewRequest(gReviewRequestId,
-                                           gReviewRequestSitePrefix,
-                                           gReviewRequestPath);
+this.gReviewRequest = new RB.ReviewRequest({
+    id: gReviewRequestId,
+    localSitePrefix: gReviewRequestSitePrefix
+});
 
 // State variables
 var gEditCount = 0;
@@ -160,6 +161,15 @@ function linkifyText(text) {
 }
 
 
+var DRAFT_FIELD_MAP = {
+    bugs_closed: 'bugsClosed',
+    change_description: 'changeDescription',
+    target_groups: 'targetGroups',
+    target_people: 'targetPeople',
+    testing_done: 'testingDone'
+};
+
+
 /*
  * Sets a field in the draft.
  *
@@ -170,60 +180,69 @@ function linkifyText(text) {
  * @param {string} value  The field value.
  */
 function setDraftField(field, value) {
-    gReviewRequest.setDraftField({
-        field: field,
-        value: value,
+    var data = {};
+
+    data[field] = value;
+
+    gReviewRequest.draft.save({
+        data: data,
         buttons: gDraftBannerButtons,
-        success: function(rsp) {
-            /* Checking if invalid user or group was entered. */
-            if (rsp.stat == "fail" && rsp.fields) {
-
-                $('#review-request-warning')
-                    .delay(6000)
-                    .fadeOut(400, function() {
-                        $(this).hide();
-                    });
+        error: function(model, xhr) {
+            var rsp = xhr.errorPayload,
+                message;
 
-                /* Wrap each term in quotes or a leading 'and'. */
-                $.each(rsp.fields[field], function(key, value) {
-                    var size = rsp.fields[field].length;
+            gPublishing = false;
 
-                    if (key == size - 1 && size > 1) {
-                      rsp.fields[field][key] = "and '" + value + "'";
-                    } else {
-                      rsp.fields[field][key] = "'" + value + "'";
-                    }
+            $('#review-request-warning')
+                .delay(6000)
+                .fadeOut(400, function() {
+                    $(this).hide();
                 });
 
-                var message = rsp.fields[field].join(", ");
+            /* Wrap each term in quotes or a leading 'and'. */
+            $.each(rsp.fields[field], function(key, value) {
+                var size = rsp.fields[field].length;
 
-                if (rsp.fields[field].length == 1) {
-                    if (field == "target_groups") {
-                        message = "Group " + message + " does not exist.";
-                    } else {
-                        message = "User " + message + " does not exist.";
-                    }
+                if (key == size - 1 && size > 1) {
+                  rsp.fields[field][key] = "and '" + value + "'";
                 } else {
-                    if (field == "target_groups") {
-                        message = "Groups " + message + " do not exist.";
-                    } else {
-                        message = "Users " + message + " do not exist.";
-                    }
+                  rsp.fields[field][key] = "'" + value + "'";
                 }
+            });
+
+            message = rsp.fields[field].join(", ");
 
-                $("#review-request-warning")
-                    .show()
-                    .html(message);
+            if (rsp.fields[field].length == 1) {
+                if (field == "target_groups") {
+                    message = "Group " + message + " does not exist.";
+                } else {
+                    message = "User " + message + " does not exist.";
+                }
+            } else {
+                if (field == "target_groups") {
+                    message = "Groups " + message + " do not exist.";
+                } else {
+                    message = "Users " + message + " do not exist.";
+                }
             }
 
-            var func = gEditorCompleteHandlers[field];
+            $("#review-request-warning")
+                .show()
+                .html(message);
+        },
+        complete: function() {
+            var func = gEditorCompleteHandlers[field],
+                fieldName;
+
+            if (_.isFunction(func)) {
+                fieldName = DRAFT_FIELD_MAP[field] || field;
 
-            if ($.isFunction(func)) {
                 $("#" + field)
-		    .empty()
-		    .html(func(rsp['draft'][field]));
+                    .empty()
+                    .html(func(gReviewRequest.draft.get(fieldName)));
             }
-
+        },
+        success: function(model) {
             gDraftBanner.show();
 
             if (gPublishing) {
@@ -233,9 +252,6 @@ function setDraftField(field, value) {
                     publishDraft();
                 }
             }
-        },
-        error: function() {
-            gPublishing = false;
         }
     });
 }
@@ -333,8 +349,11 @@ function publishDraft() {
     } else if ($.trim($("#description").html()) == "") {
         alert("The draft must have a description.");
     } else {
-        gReviewRequest.publish({
-            buttons: gDraftBannerButtons
+        gReviewRequest.draft.publish({
+            buttons: gDraftBannerButtons,
+            success: function() {
+                window.location = gReviewRequestPath;
+            }
         });
     }
 }
@@ -1478,7 +1497,7 @@ RB.registerForUpdates = function(lastTimestamp, type) {
     var faviconURL = faviconEl.attr("href");
     var faviconNotifyURL = STATIC_URLS["rb/images/favicon_notify.ico"];
 
-    $.event.add(gReviewRequest, "updated", function(evt, info) {
+    gReviewRequest.on('updated', function(info) {
         if (bubble.length == 0) {
             updateFavIcon(faviconNotifyURL);
 
@@ -1656,8 +1675,11 @@ $(document).ready(function() {
     });
 
     $("#btn-draft-discard").click(function() {
-        gReviewRequest.discardDraft({
-            options: gDraftBannerButtons
+        gReviewRequest.draft.destroy({
+            buttons: gDraftBannerButtons,
+            success: function() {
+                window.location = gReviewRequestPath;
+            }
         });
         return false;
     });
@@ -1666,7 +1688,10 @@ $(document).ready(function() {
         .click(function() {
             gReviewRequest.close({
                 type: RB.ReviewRequest.CLOSE_DISCARDED,
-                buttons: gDraftBannerButtons
+                buttons: gDraftBannerButtons,
+                success: function() {
+                    window.location = gReviewRequestPath;
+                }
             });
             return false;
         });
@@ -1687,7 +1712,10 @@ $(document).ready(function() {
         if (submit) {
             gReviewRequest.close({
                 type: RB.ReviewRequest.CLOSE_SUBMITTED,
-                buttons: gDraftBannerButtons
+                buttons: gDraftBannerButtons,
+                success: function() {
+                    window.location = gReviewRequestPath;
+                }
             });
         }
 
@@ -1696,7 +1724,10 @@ $(document).ready(function() {
 
     $("#btn-review-request-reopen").click(function() {
         gReviewRequest.reopen({
-            buttons: gDraftBannerButtons
+            buttons: gDraftBannerButtons,
+            success: function() {
+                window.location = gReviewRequestPath;
+            }
         });
 
         return false;
@@ -1712,7 +1743,7 @@ $(document).ready(function() {
                     $('<input type="button" value="Cancel"/>'),
                     $('<input type="button" value="Delete"/>')
                         .click(function(e) {
-                            gReviewRequest.deletePermanently({
+                            gReviewRequest.destroy({
                                 buttons: gDraftBannerButtons.add(
                                     $("input", dlg.modalBox("buttons"))),
                                 success: function() {
