Add new REST API call for querying revision actions

If you have enabled the new option `submitWholeTopic`, we eventually want
to query all changes in the topic if they can be submitted (Code-Review +2,
Verified and the user has permission to submit) to determine if the
submit button is enabled in a ChangeScreen. This query may take some
time, so we don't want to have it in the same query for the whole
change screen, which should render fast.

This commit introduces a new REST API call which allows to query
for the available actions for a given revision id:

  changes/<id>/revisions/<id>/actions

By introducing a new ActionJson class we can move all actions related
JSON processing out of ChangeJson eventually which currently
covers actions such as in the change/<id>/detail REST API call.

Change-Id: I58c67944e756f0b130a9c1d89d83f7635bf474a7
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index bd72353..115cee5 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -1873,6 +1873,46 @@
 Adding query parameter `links` (for example `/changes/.../commit?links`)
 returns a link:#commit-info[CommitInfo] with the additional field `web_links`.
 
+[[get-revision-actions]]
+=== Get Revision Actions
+--
+'GET /changes/link:#change-id[\{change-id\}]/revisions/link:#revision-id[\{revision-id\}]/actions'
+--
+
+Retrieves revision link:#action-info[actions] of the revision of a change.
+
+.Request
+----
+  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/actions' HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+
+{
+  "submit": {
+    "method": "POST",
+    "label": "Submit",
+    "title": "Submit patch set 1 into master",
+    "enabled": true
+  },
+  "cherrypick": {
+    "method": "POST",
+    "label": "Cherry Pick",
+    "title": "Cherry pick change to a different branch",
+    "enabled": true
+  }
+}
+----
+
+The response is a flat map of possible revision actions mapped to their
+link:#action-info[ActionInfo].
+
 [[get-review]]
 === Get Review
 --
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ActionsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ActionsIT.java
new file mode 100644
index 0000000..b463a53
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/ActionsIT.java
@@ -0,0 +1,137 @@
+// Copyright (C) 2015 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.change;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.common.ActionInfo;
+import com.google.gerrit.testutil.ConfigSuite;
+import com.google.gson.reflect.TypeToken;
+
+import org.apache.http.HttpStatus;
+import org.eclipse.jgit.api.errors.GitAPIException;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Test;
+
+import java.io.IOException;
+import java.util.Map;
+
+public class ActionsIT extends AbstractDaemonTest {
+  @ConfigSuite.Config
+  public static Config submitWholeTopicEnabled() {
+    return submitWholeTopicEnabledConfig();
+  }
+
+  @Test
+  public void revisionActionsOneChangePerTopic() throws Exception {
+    String changeId = createChangeWithTopic("foo1").getChangeId();
+    approve(changeId);
+    Map<String, ActionInfo> actions = getActions(changeId);
+    assertThat(actions).containsKey("cherrypick");
+    assertThat(actions).containsKey("submit");
+    if (isSubmitWholeTopicEnabled()) {
+      ActionInfo info = actions.get("submit");
+      assertThat(info.enabled).isTrue();
+      assertThat(info.label).isEqualTo("Submit whole topic");
+      assertThat(info.method).isEqualTo("POST");
+      assertThat(info.title).isEqualTo("Submit all 1 changes of the same topic");
+    } else {
+      noSubmitWholeTopicAssertions(actions);
+    }
+    // no other actions
+    assertThat(actions).hasSize(2);
+  }
+
+  @Test
+  public void revisionActionsTwoChangeChangesInTopic() throws Exception {
+    String changeId = createChangeWithTopic("foo2").getChangeId();
+    approve(changeId);
+    // create another change with the same topic
+    createChangeWithTopic("foo2").getChangeId();
+    Map<String, ActionInfo> actions = getActions(changeId);
+    assertThat(actions).containsKey("cherrypick");
+    assertThat(actions).containsKey("submit");
+    // no other actions:
+    assertThat(actions).hasSize(2);
+    if (isSubmitWholeTopicEnabled()) {
+      ActionInfo info = actions.get("submit");
+      assertThat(info.enabled).isNull();
+      assertThat(info.label).isEqualTo("Submit whole topic");
+      assertThat(info.method).isEqualTo("POST");
+      assertThat(info.title).isEqualTo("Other changes in this topic are not ready");
+    } else {
+      noSubmitWholeTopicAssertions(actions);
+    }
+  }
+
+  @Test
+  public void revisionActionsTwoChangeChangesInTopicReady() throws Exception {
+    String changeId = createChangeWithTopic("foo2").getChangeId();
+    approve(changeId);
+    // create another change with the same topic
+    String changeId2 = createChangeWithTopic("foo2").getChangeId();
+    approve(changeId2);
+    Map<String, ActionInfo> actions = getActions(changeId);
+    assertThat(actions).containsKey("cherrypick");
+    assertThat(actions).containsKey("submit");
+    // no other actions:
+    assertThat(actions).hasSize(2);
+    if (isSubmitWholeTopicEnabled()) {
+      ActionInfo info = actions.get("submit");
+      assertThat(info.enabled).isTrue();
+      assertThat(info.label).isEqualTo("Submit whole topic");
+      assertThat(info.method).isEqualTo("POST");
+      assertThat(info.title).isEqualTo("Submit all 2 changes of the same topic");
+    } else {
+      noSubmitWholeTopicAssertions(actions);
+    }
+  }
+
+  private Map<String, ActionInfo> getActions(String changeId)
+      throws IOException {
+    return newGson().fromJson(
+        adminSession.get("/changes/"
+            + changeId
+            + "/revisions/1/actions").getReader(),
+        new TypeToken<Map<String, ActionInfo>>() {}.getType());
+  }
+
+  private void noSubmitWholeTopicAssertions(Map<String, ActionInfo> actions) {
+    ActionInfo info = actions.get("submit");
+    assertThat(info.enabled).isTrue();
+    assertThat(info.label).isEqualTo("Submit");
+    assertThat(info.method).isEqualTo("POST");
+    assertThat(info.title).isEqualTo("Submit patch set 1 into master");
+  }
+
+  private PushOneCommit.Result createChangeWithTopic(String topic) throws GitAPIException,
+      IOException {
+    PushOneCommit push = pushFactory.create(db, admin.getIdent());
+    assertThat(topic).isNotEmpty();
+    return push.to(git, "refs/for/master/" + topic);
+  }
+
+  private void approve(String changeId) throws IOException {
+    RestResponse r = adminSession.post(
+        "/changes/" + changeId + "/revisions/current/review",
+        new ReviewInput().label("Code-Review", 2));
+    assertThat(r.getStatusCode()).isEqualTo(HttpStatus.SC_OK);
+    r.consume();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ActionJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ActionJson.java
new file mode 100644
index 0000000..24837aab
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ActionJson.java
@@ -0,0 +1,54 @@
+// Copyright (C) 2015 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.change;
+
+import com.google.gerrit.extensions.common.ActionInfo;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.extensions.webui.UiActions;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import com.google.inject.util.Providers;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+@Singleton
+public class ActionJson {
+  private final Revisions revisions;
+
+  @Inject
+  ActionJson(Revisions revisions) {
+    this.revisions = revisions;
+  }
+
+  public Map<String, ActionInfo> format(RevisionResource rsrc) {
+    return toActionMap(rsrc);
+  }
+
+  private Map<String, ActionInfo> toActionMap(RevisionResource rsrc) {
+    Map<String, ActionInfo> out = new LinkedHashMap<>();
+    if (rsrc.getControl().getCurrentUser().isIdentifiedUser()) {
+      Provider<CurrentUser> userProvider = Providers.of(
+          rsrc.getControl().getCurrentUser());
+      for (UiAction.Description d : UiActions.from(
+          revisions, rsrc, userProvider)) {
+        out.put(d.getId(), new ActionInfo(d));
+      }
+    }
+    return out;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRevisionActions.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRevisionActions.java
new file mode 100644
index 0000000..d58c8d2
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRevisionActions.java
@@ -0,0 +1,35 @@
+// Copyright (C) 2015 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.change;
+
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+@Singleton
+public class GetRevisionActions implements RestReadView<RevisionResource> {
+  private final ActionJson delegate;
+
+  @Inject
+  GetRevisionActions(ActionJson delegate) {
+    this.delegate = delegate;
+  }
+
+  @Override
+  public Object apply(RevisionResource rsrc) {
+    return Response.withMustRevalidate(delegate.format(rsrc));
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Module.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Module.java
index ac4489e..d0e4c99 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Module.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Module.java
@@ -73,6 +73,7 @@
     delete(REVIEWER_KIND).to(DeleteReviewer.class);
 
     child(CHANGE_KIND, "revisions").to(Revisions.class);
+    get(REVISION_KIND, "actions").to(GetRevisionActions.class);
     post(REVISION_KIND, "cherrypick").to(CherryPick.class);
     get(REVISION_KIND, "commit").to(GetCommit.class);
     delete(REVISION_KIND).to(DeleteDraftPatchSet.class);