Merge "Add REST API to toggle starred change state" into stable-2.8
diff --git a/Documentation/rest-api-accounts.txt b/Documentation/rest-api-accounts.txt
index 02bb549..fdea3c7 100644
--- a/Documentation/rest-api-accounts.txt
+++ b/Documentation/rest-api-accounts.txt
@@ -921,6 +921,84 @@
   }
 ----
 
+Get Starred Changes
+~~~~~~~~~~~~~~~~~~~
+[verse]
+'GET /accounts/link:#account-id[\{account-id\}]/starred.changes'
+
+Gets the changes starred by the identified user account. This
+URL endpoint is functionally identical to the changes query
+`GET /changes/?q=is:starred`. The result is a list of
+link:rest-api-changes.html#change-info[ChangeInfo] entities.
+
+.Request
+----
+  GET /a/accounts/self/starred.changes
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json;charset=UTF-8
+
+  )]}'
+  [
+    {
+      "kind": "gerritcodereview#change",
+      "id": "myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940",
+      "project": "myProject",
+      "branch": "master",
+      "change_id": "I8473b95934b5732ac55d26311a706c9c2bde9940",
+      "subject": "Implementing Feature X",
+      "status": "NEW",
+      "created": "2013-02-01 09:59:32.126000000",
+      "updated": "2013-02-21 11:16:36.775000000",
+      "mergeable": true,
+      "_sortkey": "0023412400000f7d",
+      "_number": 3965,
+      "owner": {
+        "name": "John Doe"
+      }
+    }
+  ]
+----
+
+Star Change
+~~~~~~~~~~~
+[verse]
+'PUT /accounts/link:#account-id[\{account-id\}]/starred.changes/link:rest-api-changes.html#change-id[\{change-id\}]'
+
+Star a change. Starred changes are returned for the search query
+`is:starred` or `starredby:USER` and automatically notify the user
+whenever updates are made to the change.
+
+.Request
+----
+  PUT /a/accounts/self/starred.changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940 HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 204 No Content
+----
+
+Unstar Change
+~~~~~~~~~~~~~
+[verse]
+'DELETE /accounts/link:#account-id[\{account-id\}]/starred.changes/link:rest-api-changes#change-id[\{change-id\}]'
+
+Unstar a change. Removes the starred flag, stopping notifications.
+
+.Request
+----
+  DELETE /a/accounts/self/starred.changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940 HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 204 No Content
+----
 
 [[ids]]
 IDs
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/BUCK b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/BUCK
index d813501..1fca451 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/BUCK
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/BUCK
@@ -2,7 +2,11 @@
 
 acceptance_tests(
   srcs = glob(['*IT.java']),
-  deps = [':util'],
+  deps = [
+    ':util',
+    '//gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git:util',
+    '//gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change:util',
+  ],
 )
 
 java_library(
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/StarredChangesIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/StarredChangesIT.java
new file mode 100644
index 0000000..b5ae7de
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/StarredChangesIT.java
@@ -0,0 +1,122 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.rest.account;
+
+import static com.google.gerrit.acceptance.git.GitUtil.cloneProject;
+import static com.google.gerrit.acceptance.git.GitUtil.createProject;
+import static com.google.gerrit.acceptance.git.GitUtil.initSsh;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.AccountCreator;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.RestSession;
+import com.google.gerrit.acceptance.SshSession;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.git.PushOneCommit;
+import com.google.gerrit.acceptance.git.PushOneCommit.Result;
+import com.google.gerrit.acceptance.rest.change.ChangeInfo;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gson.Gson;
+import com.google.gson.reflect.TypeToken;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.Inject;
+
+import org.eclipse.jgit.api.Git;
+import org.eclipse.jgit.api.errors.GitAPIException;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.io.IOException;
+import java.util.List;
+
+public class StarredChangesIT extends AbstractDaemonTest {
+
+  @Inject
+  private AccountCreator accounts;
+
+  @Inject
+  private SchemaFactory<ReviewDb> reviewDbProvider;
+
+  private TestAccount admin;
+
+  private RestSession session;
+  private Git git;
+  private ReviewDb db;
+
+  @Before
+  public void setUp() throws Exception {
+    admin = accounts.admin();
+    session = new RestSession(server, admin);
+    initSsh(admin);
+    Project.NameKey project = new Project.NameKey("p");
+    SshSession sshSession = new SshSession(server, admin);
+    createProject(sshSession, project.get());
+    git = cloneProject(sshSession.getUrl() + "/" + project.get());
+    sshSession.close();
+    db = reviewDbProvider.open();
+  }
+
+  @After
+  public void cleanup() {
+    db.close();
+  }
+
+  @Test
+  public void starredChangeState() throws GitAPIException, IOException,
+      OrmException {
+    Result c1 = createChange();
+    Result c2 = createChange();
+    assertNull(getChange(c1.getChangeId()).starred);
+    assertNull(getChange(c2.getChangeId()).starred);
+    starChange(true, c1.getPatchSetId().getParentKey());
+    starChange(true, c2.getPatchSetId().getParentKey());
+    assertTrue(getChange(c1.getChangeId()).starred);
+    assertTrue(getChange(c2.getChangeId()).starred);
+    starChange(false, c1.getPatchSetId().getParentKey());
+    starChange(false, c2.getPatchSetId().getParentKey());
+    assertNull(getChange(c1.getChangeId()).starred);
+    assertNull(getChange(c2.getChangeId()).starred);
+  }
+
+  private ChangeInfo getChange(String changeId) throws IOException {
+    RestResponse r = session.get("/changes/?q=" + changeId);
+    List<ChangeInfo> c = (new Gson()).fromJson(r.getReader(),
+        new TypeToken<List<ChangeInfo>>() {}.getType());
+    return c.get(0);
+  }
+
+  private void starChange(boolean on, Change.Id id) throws IOException {
+    String url = "/accounts/self/starred.changes/" + id.get();
+    if (on) {
+      RestResponse r = session.put(url);
+      assertEquals(204, r.getStatusCode());
+    } else {
+      RestResponse r = session.delete(url);
+      assertEquals(204, r.getStatusCode());
+    }
+  }
+
+  private Result createChange() throws GitAPIException, IOException {
+    PushOneCommit push = new PushOneCommit(db, admin.getIdent());
+    return push.to(git, "refs/for/master");
+  }
+}
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/BUCK b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/BUCK
index b9c2d08..20b1033 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/BUCK
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/BUCK
@@ -38,4 +38,5 @@
     '//lib:guava',
     '//gerrit-reviewdb:server',
   ],
+  visibility = ['//gerrit-acceptance-tests/...'],
 )
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeInfo.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeInfo.java
index fe8737e..8b431f0 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeInfo.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ChangeInfo.java
@@ -24,4 +24,5 @@
   String branch;
   List<ChangeMessageInfo> messages;
   Change.Status status;
+  public Boolean starred;
 }
diff --git a/gerrit-common/src/main/java/com/google/gerrit/common/data/ChangeListService.java b/gerrit-common/src/main/java/com/google/gerrit/common/data/ChangeListService.java
deleted file mode 100644
index 0c466497..0000000
--- a/gerrit-common/src/main/java/com/google/gerrit/common/data/ChangeListService.java
+++ /dev/null
@@ -1,35 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.common.data;
-
-import com.google.gerrit.common.audit.Audit;
-import com.google.gerrit.common.auth.SignInRequired;
-import com.google.gwtjsonrpc.common.AsyncCallback;
-import com.google.gwtjsonrpc.common.RemoteJsonService;
-import com.google.gwtjsonrpc.common.RpcImpl;
-import com.google.gwtjsonrpc.common.VoidResult;
-import com.google.gwtjsonrpc.common.RpcImpl.Version;
-
-@RpcImpl(version = Version.V2_0)
-public interface ChangeListService extends RemoteJsonService {
-  /**
-   * Add and/or remove changes from the set of starred changes of the caller.
-   *
-   * @param req the add and remove cluster.
-   */
-  @Audit
-  @SignInRequired
-  void toggleStars(ToggleStarRequest req, AsyncCallback<VoidResult> callback);
-}
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/StarredChanges.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/StarredChanges.java
index 7d2084a..b097bd8 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/StarredChanges.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/StarredChanges.java
@@ -15,19 +15,23 @@
 package com.google.gerrit.client.changes;
 
 import com.google.gerrit.client.Gerrit;
-import com.google.gerrit.client.rpc.GerritCallback;
-import com.google.gerrit.common.data.ToggleStarRequest;
+import com.google.gerrit.client.account.AccountApi;
+import com.google.gerrit.client.rpc.RestApi;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gwt.core.client.JavaScriptObject;
 import com.google.gwt.event.dom.client.ClickEvent;
 import com.google.gwt.event.dom.client.ClickHandler;
 import com.google.gwt.event.dom.client.KeyPressEvent;
 import com.google.gwt.resources.client.ImageResource;
+import com.google.gwt.user.client.rpc.AsyncCallback;
 import com.google.gwt.user.client.ui.Image;
 import com.google.gwtexpui.globalkey.client.KeyCommand;
-import com.google.gwtjsonrpc.common.VoidResult;
 import com.google.web.bindery.event.shared.Event;
 import com.google.web.bindery.event.shared.HandlerRegistration;
 
+import java.util.LinkedHashMap;
+import java.util.Map;
+
 /** Supports the star icon displayed on changes and tracking the status. */
 public class StarredChanges {
   private static final Event.Type<ChangeStarHandler> TYPE =
@@ -105,57 +109,52 @@
   public static void toggleStar(
       final Change.Id changeId,
       final boolean newValue) {
-    if (next == null) {
-      next = new ToggleStarRequest();
-    }
-    next.toggle(changeId, newValue);
+    pending.put(changeId, newValue);
     fireChangeStarEvent(changeId, newValue);
     if (!busy) {
-      start();
+      startRequest();
     }
   }
 
-  private static ToggleStarRequest next;
   private static boolean busy;
+  private static final Map<Change.Id, Boolean> pending =
+      new LinkedHashMap<Change.Id, Boolean>(4);
 
-  private static void start() {
-    final ToggleStarRequest req = next;
-    next = null;
+  private static void startRequest() {
     busy = true;
 
-    Util.LIST_SVC.toggleStars(req, new GerritCallback<VoidResult>() {
+    final Change.Id id = pending.keySet().iterator().next();
+    final boolean starred = pending.remove(id);
+    RestApi call = AccountApi.self().view("starred.changes").id(id.get());
+    AsyncCallback<JavaScriptObject> cb = new AsyncCallback<JavaScriptObject>() {
       @Override
-      public void onSuccess(VoidResult result) {
-        if (next != null) {
-          start();
-        } else {
+      public void onSuccess(JavaScriptObject none) {
+        if (pending.isEmpty()) {
           busy = false;
+        } else {
+          startRequest();
         }
       }
 
       @Override
       public void onFailure(Throwable caught) {
-        rollback(req);
-        if (next != null) {
-          rollback(next);
-          next = null;
+        if (!starred && RestApi.isStatus(caught, 404)) {
+          onSuccess(null);
+          return;
         }
-        busy = false;
-        super.onFailure(caught);
-      }
-    });
-  }
 
-  private static void rollback(ToggleStarRequest req) {
-    if (req.getAddSet() != null) {
-      for (Change.Id id : req.getAddSet()) {
-        fireChangeStarEvent(id, false);
+        fireChangeStarEvent(id, !starred);
+        for (Map.Entry<Change.Id, Boolean> e : pending.entrySet()) {
+          fireChangeStarEvent(e.getKey(), !e.getValue());
+        }
+        pending.clear();
+        busy = false;
       }
-    }
-    if (req.getRemoveSet() != null) {
-      for (Change.Id id : req.getRemoveSet()) {
-        fireChangeStarEvent(id, true);
-      }
+    };
+    if (starred) {
+      call.put(cb);
+    } else {
+      call.delete(cb);
     }
   }
 
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/Util.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/Util.java
index 590ad87..76dfd58 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/Util.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/Util.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.client.changes;
 
 import com.google.gerrit.common.data.ChangeDetailService;
-import com.google.gerrit.common.data.ChangeListService;
 import com.google.gerrit.common.data.ChangeManageService;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gwt.core.client.GWT;
@@ -27,7 +26,6 @@
   public static final ChangeResources R = GWT.create(ChangeResources.class);
 
   public static final ChangeDetailService DETAIL_SVC;
-  public static final ChangeListService LIST_SVC;
   public static final ChangeManageService MANAGE_SVC;
 
   private static final int SUBJECT_MAX_LENGTH = 80;
@@ -38,9 +36,6 @@
     DETAIL_SVC = GWT.create(ChangeDetailService.class);
     JsonUtil.bind(DETAIL_SVC, "rpc/ChangeDetailService");
 
-    LIST_SVC = GWT.create(ChangeListService.class);
-    JsonUtil.bind(LIST_SVC, "rpc/ChangeListService");
-
     MANAGE_SVC = GWT.create(ChangeManageService.class);
     JsonUtil.bind(MANAGE_SVC, "rpc/ChangeManageService");
   }
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/ChangeListServiceImpl.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/ChangeListServiceImpl.java
deleted file mode 100644
index 0b54db1..0000000
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/ChangeListServiceImpl.java
+++ /dev/null
@@ -1,74 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.httpd.rpc;
-
-import com.google.gerrit.common.data.ChangeListService;
-import com.google.gerrit.common.data.ToggleStarRequest;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.StarredChange;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gwtjsonrpc.common.AsyncCallback;
-import com.google.gwtjsonrpc.common.VoidResult;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Set;
-
-public class ChangeListServiceImpl extends BaseServiceImplementation implements
-    ChangeListService {
-  private final Provider<CurrentUser> currentUser;
-
-  @Inject
-  ChangeListServiceImpl(final Provider<ReviewDb> schema,
-      final Provider<CurrentUser> currentUser) {
-    super(schema, currentUser);
-    this.currentUser = currentUser;
-  }
-
-  public void toggleStars(final ToggleStarRequest req,
-      final AsyncCallback<VoidResult> callback) {
-    run(callback, new Action<VoidResult>() {
-      public VoidResult run(final ReviewDb db) throws OrmException {
-        final Account.Id me = getAccountId();
-        final Set<Change.Id> existing = currentUser.get().getStarredChanges();
-        List<StarredChange> add = new ArrayList<StarredChange>();
-        List<StarredChange.Key> remove = new ArrayList<StarredChange.Key>();
-
-        if (req.getAddSet() != null) {
-          for (final Change.Id id : req.getAddSet()) {
-            if (!existing.contains(id)) {
-              add.add(new StarredChange(new StarredChange.Key(me, id)));
-            }
-          }
-        }
-
-        if (req.getRemoveSet() != null) {
-          for (final Change.Id id : req.getRemoveSet()) {
-            remove.add(new StarredChange.Key(me, id));
-          }
-        }
-
-        db.starredChanges().insert(add);
-        db.starredChanges().deleteKeys(remove);
-        return VoidResult.INSTANCE;
-      }
-    });
-  }
-}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/UiRpcModule.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/UiRpcModule.java
index 7de332a..08e1582 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/UiRpcModule.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/UiRpcModule.java
@@ -27,7 +27,6 @@
 
   @Override
   protected void configureServlets() {
-    rpc(ChangeListServiceImpl.class);
     rpc(SuggestServiceImpl.class);
     rpc(SystemInfoServiceImpl.class);
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountResource.java
index 629bd15..106c033 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountResource.java
@@ -17,7 +17,9 @@
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.reviewdb.client.AccountSshKey;
+import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.change.ChangeResource;
 import com.google.inject.TypeLiteral;
 
 public class AccountResource implements RestResource {
@@ -33,6 +35,9 @@
   public static final TypeLiteral<RestView<SshKey>> SSH_KEY_KIND =
       new TypeLiteral<RestView<SshKey>>() {};
 
+  public static final TypeLiteral<RestView<StarredChange>> STARRED_CHANGE_KIND =
+      new TypeLiteral<RestView<StarredChange>>() {};
+
   private final IdentifiedUser user;
 
   public AccountResource(IdentifiedUser user) {
@@ -90,4 +95,17 @@
       return sshKey;
     }
   }
+
+  public static class StarredChange extends AccountResource {
+    private final ChangeResource change;
+
+    public StarredChange(IdentifiedUser user, ChangeResource change) {
+      super(user);
+      this.change = change;
+    }
+
+    public Change getChange() {
+      return change.getChange();
+    }
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/Module.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/Module.java
index 79a0089..11f2e91 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/Module.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/Module.java
@@ -18,6 +18,7 @@
 import static com.google.gerrit.server.account.AccountResource.CAPABILITY_KIND;
 import static com.google.gerrit.server.account.AccountResource.EMAIL_KIND;
 import static com.google.gerrit.server.account.AccountResource.SSH_KEY_KIND;
+import static com.google.gerrit.server.account.AccountResource.STARRED_CHANGE_KIND;
 
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.RestApiModule;
@@ -30,9 +31,10 @@
     bind(Capabilities.class);
 
     DynamicMap.mapOf(binder(), ACCOUNT_KIND);
+    DynamicMap.mapOf(binder(), CAPABILITY_KIND);
     DynamicMap.mapOf(binder(), EMAIL_KIND);
     DynamicMap.mapOf(binder(), SSH_KEY_KIND);
-    DynamicMap.mapOf(binder(), CAPABILITY_KIND);
+    DynamicMap.mapOf(binder(), STARRED_CHANGE_KIND);
 
     put(ACCOUNT_KIND).to(PutAccount.class);
     get(ACCOUNT_KIND).to(GetAccount.class);
@@ -65,6 +67,11 @@
     put(ACCOUNT_KIND, "preferences.diff").to(SetDiffPreferences.class);
     get(CAPABILITY_KIND).to(GetCapabilities.CheckOne.class);
 
+    child(ACCOUNT_KIND, "starred.changes").to(StarredChanges.class);
+    put(STARRED_CHANGE_KIND).to(StarredChanges.Put.class);
+    delete(STARRED_CHANGE_KIND).to(StarredChanges.Delete.class);
+    bind(StarredChanges.Create.class);
+
     install(new FactoryModuleBuilder().build(CreateAccount.Factory.class));
     install(new FactoryModuleBuilder().build(CreateEmail.Factory.class));
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/StarredChanges.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/StarredChanges.java
new file mode 100644
index 0000000..0e335d0
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/StarredChanges.java
@@ -0,0 +1,199 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AcceptsCreate;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.StarredChange;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.ChangesCollection;
+import com.google.gerrit.server.query.change.QueryChanges;
+import com.google.gwtorm.server.OrmDuplicateKeyException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.UnsupportedEncodingException;
+import java.util.Collections;
+
+class StarredChanges implements
+    ChildCollection<AccountResource, AccountResource.StarredChange>,
+    AcceptsCreate<AccountResource> {
+  private static final Logger log = LoggerFactory.getLogger(StarredChanges.class);
+
+  private final ChangesCollection changes;
+  private final DynamicMap<RestView<AccountResource.StarredChange>> views;
+  private final Provider<Create> createProvider;
+
+  @Inject
+  StarredChanges(ChangesCollection changes,
+      DynamicMap<RestView<AccountResource.StarredChange>> views,
+      Provider<Create> createProvider) {
+    this.changes = changes;
+    this.views = views;
+    this.createProvider = createProvider;
+  }
+
+  @Override
+  public AccountResource.StarredChange parse(AccountResource parent, IdString id)
+      throws ResourceNotFoundException, OrmException, UnsupportedEncodingException {
+    IdentifiedUser user = parent.getUser();
+    try {
+      user.asyncStarredChanges();
+
+      ChangeResource change = changes.parse(TopLevelResource.INSTANCE, id);
+      if (user.getStarredChanges().contains(change.getChange().getId())) {
+        return new AccountResource.StarredChange(user, change);
+      }
+      throw new ResourceNotFoundException(id);
+    } finally {
+      user.abortStarredChanges();
+    }
+  }
+
+  @Override
+  public DynamicMap<RestView<AccountResource.StarredChange>> views() {
+    return views;
+  }
+
+  @Override
+  public RestView<AccountResource> list() throws ResourceNotFoundException {
+    return new RestReadView<AccountResource>() {
+      @Override
+      public Object apply(AccountResource self) throws BadRequestException,
+          AuthException, OrmException {
+        QueryChanges query = changes.list();
+        query.addQuery("starredby:" + self.getUser().getAccountId().get());
+        return query.apply(TopLevelResource.INSTANCE);
+      }
+    };
+  }
+
+  @SuppressWarnings("unchecked")
+  @Override
+  public RestModifyView<AccountResource, EmptyInput> create(
+      AccountResource parent, IdString id) throws UnprocessableEntityException{
+    try {
+      return createProvider.get()
+          .setChange(changes.parse(TopLevelResource.INSTANCE, id));
+    } catch (ResourceNotFoundException e) {
+      throw new UnprocessableEntityException(String.format("change %s not found", id.get()));
+    } catch (UnsupportedEncodingException e) {
+      log.error("cannot resolve change", e);
+      throw new UnprocessableEntityException("internal server error");
+    } catch (OrmException e) {
+      log.error("cannot resolve change", e);
+      throw new UnprocessableEntityException("internal server error");
+    }
+  }
+
+  static class Create implements RestModifyView<AccountResource, EmptyInput> {
+    private final Provider<CurrentUser> self;
+    private final Provider<ReviewDb> dbProvider;
+    private ChangeResource change;
+
+    @Inject
+    Create(Provider<CurrentUser> self, Provider<ReviewDb> dbProvider) {
+      this.self = self;
+      this.dbProvider = dbProvider;
+    }
+
+    Create setChange(ChangeResource change) {
+      this.change = change;
+      return this;
+    }
+
+    @Override
+    public Response<?> apply(AccountResource rsrc, EmptyInput in)
+        throws AuthException, OrmException {
+      if (self.get() != rsrc.getUser()) {
+        throw new AuthException("not allowed to add starred change");
+      }
+      try {
+        dbProvider.get().starredChanges().insert(Collections.singleton(
+            new StarredChange(new StarredChange.Key(
+                rsrc.getUser().getAccountId(),
+                change.getChange().getId()))));
+      } catch (OrmDuplicateKeyException e) {
+        return Response.none();
+      }
+      return Response.none();
+    }
+  }
+
+  static class Put implements
+      RestModifyView<AccountResource.StarredChange, EmptyInput> {
+    private final Provider<CurrentUser> self;
+
+    @Inject
+    Put(Provider<CurrentUser> self) {
+      this.self = self;
+    }
+
+    @Override
+    public Response<?> apply(AccountResource.StarredChange rsrc, EmptyInput in)
+        throws AuthException, OrmException {
+      if (self.get() != rsrc.getUser()) {
+        throw new AuthException("not allowed update starred changes");
+      }
+      return Response.none();
+    }
+  }
+
+  static class Delete implements
+      RestModifyView<AccountResource.StarredChange, EmptyInput> {
+    private final Provider<CurrentUser> self;
+    private final Provider<ReviewDb> dbProvider;
+
+    @Inject
+    Delete(Provider<CurrentUser> self, Provider<ReviewDb> dbProvider) {
+      this.self = self;
+      this.dbProvider = dbProvider;
+    }
+
+    @Override
+    public Response<?> apply(AccountResource.StarredChange rsrc,
+        EmptyInput in) throws AuthException, OrmException {
+      if (self.get() != rsrc.getUser()) {
+        throw new AuthException("not allowed remove starred change");
+      }
+      dbProvider.get().starredChanges().delete(Collections.singleton(
+          new StarredChange(new StarredChange.Key(
+              rsrc.getUser().getAccountId(),
+              rsrc.getChange().getId()))));
+      return Response.none();
+    }
+  }
+
+  static class EmptyInput {
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangesCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangesCollection.java
index ecb83b48..e93a0d8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangesCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangesCollection.java
@@ -58,7 +58,7 @@
   }
 
   @Override
-  public RestView<TopLevelResource> list() {
+  public QueryChanges list() {
     return queryFactory.get();
   }