Plugin that enables administrators to change PatchSet to draft

Administrators sometimes need to set PatchSets as draft, to enable
users to remove PatchSets, for reasons such as

- User has uploaded huge files that we don't want in the repository,
  even if only on the refs/changes namespace.

- User has uploaded proprietary or secret code that for legal reasons
  must not be stored on the Gerrit server.

Today this operation is done by setting the PatchSet to draft directly
in the review database. This operation is troublesome and error-prone,
due to the human factor.

This plugin enables administrators to set the PatchSet to draft with a
simple ssh command.

Change-Id: Id9991aa3ee9249ec371f406e84dbc1a28e0a9bb7
diff --git a/BUCK b/BUCK
new file mode 100644
index 0000000..b08fee3
--- /dev/null
+++ b/BUCK
@@ -0,0 +1,8 @@
+gerrit_plugin(
+  name = 'force-draft',
+  srcs = glob(['src/main/java/**/*.java']),
+  resources = glob(['src/main/resources/**/*']),
+  manifest_entries = [
+    'Gerrit-SshModule: com.googlesource.gerrit.plugins.forcedraft.ForceDraftSshModule',
+  ]
+)
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000..6367111
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,84 @@
+<?xml version="1.0"?>
+<!--
+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.
+-->
+<project
+  xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"
+  xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
+  <modelVersion>4.0.0</modelVersion>
+
+  <artifactId>force-draft</artifactId>
+  <name>force-draft</name>
+  <groupId>com.googlesource.gerrit.plugins.forcedraft</groupId>
+  <packaging>jar</packaging>
+  <version>2.9-SNAPSHOT</version>
+
+  <properties>
+    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+    <Gerrit-ApiType>plugin</Gerrit-ApiType>
+    <Gerrit-ApiVersion>${project.version}</Gerrit-ApiVersion>
+  </properties>
+
+  <build>
+    <plugins>
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-jar-plugin</artifactId>
+        <version>2.4</version>
+        <configuration>
+          <archive>
+            <manifestEntries>
+              <Implementation-Title>Plugin ${project.artifactId}</Implementation-Title>
+              <Implementation-Version>${project.version}</Implementation-Version>
+              <Implementation-Vendor>Gerrit Code Review</Implementation-Vendor>
+              <Implementation-Vendor-URL>http://code.google.com/p/gerrit/</Implementation-Vendor-URL>
+              <Gerrit-ApiType>${Gerrit-ApiType}</Gerrit-ApiType>
+              <Gerrit-ApiVersion>${Gerrit-ApiVersion}</Gerrit-ApiVersion>
+              <Gerrit-SshModule>com.googlesource.gerrit.plugins.forcedraft.ForceDraftSshModule</Gerrit-SshModule>
+            </manifestEntries>
+          </archive>
+        </configuration>
+      </plugin>
+
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-compiler-plugin</artifactId>
+        <version>2.3.2</version>
+        <configuration>
+          <source>1.6</source>
+          <target>1.6</target>
+          <encoding>UTF-8</encoding>
+        </configuration>
+      </plugin>
+    </plugins>
+  </build>
+
+  <dependencies>
+    <dependency>
+      <groupId>com.google.gerrit</groupId>
+      <artifactId>gerrit-${Gerrit-ApiType}-api</artifactId>
+      <version>${Gerrit-ApiVersion}</version>
+      <scope>provided</scope>
+    </dependency>
+  </dependencies>
+
+  <repositories>
+    <repository>
+      <id>gerrit-api-repository</id>
+      <url>https://gerrit-api.commondatastorage.googleapis.com/release/</url>
+    </repository>
+  </repositories>
+
+</project>
diff --git a/src/main/java/com/googlesource/gerrit/plugins/forcedraft/ForceDraft.java b/src/main/java/com/googlesource/gerrit/plugins/forcedraft/ForceDraft.java
new file mode 100644
index 0000000..cf8dab7
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/forcedraft/ForceDraft.java
@@ -0,0 +1,248 @@
+// 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.googlesource.gerrit.plugins.forcedraft;
+
+import org.eclipse.jgit.lib.Config;
+import org.kohsuke.args4j.Argument;
+
+import com.google.gerrit.extensions.annotations.CapabilityScope;
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.gerrit.common.data.GerritConfig;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gwtorm.server.AtomicUpdate;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+@RequiresCapability(value = GlobalCapability.ADMINISTRATE_SERVER, scope = CapabilityScope.CORE)
+@CommandMetaData(name = "force-draft", description = "changes patch set to draft")
+public class ForceDraft extends SshCommand {
+
+  private static final String CHANGE_SECTION = "change";
+
+  private static final String ALLOW_DRAFT = "allowDrafts";
+
+  /**
+   * The PatchSet specified by argument.
+   */
+  private PatchSet patchSet;
+
+  /**
+   * Parent Change for patchSet.
+   */
+  private Change parentChange;
+
+  @Inject
+  private Provider<ReviewDb> dbProvider;
+
+  @Inject
+  private @GerritServerConfig Config config;
+
+  @Argument(index = 0, required = true, usage = "<change, patch set> to be changed to draft")
+  private void addPatchSetId(final String token) {
+    try {
+      patchSet = parsePatchSet(token);
+      parentChange = getParentChange();
+    } catch (UnloggedFailure e) {
+      throw new IllegalArgumentException(e.getMessage(), e);
+    } catch (OrmException e) {
+      throw new IllegalArgumentException("database error", e);
+    }
+  }
+
+  /**
+   * Gets parent Change for patchSet.
+   *
+   * @return The parent Change
+   * @throws OrmException
+   */
+  private Change getParentChange() throws OrmException {
+    Change parentChange =
+        dbProvider.get().changes().get(patchSet.getId().getParentKey());
+    return parentChange;
+  }
+
+  /**
+   * Parses a string formatted as <Change id>,<PatchSet number>
+   *
+   * @param changePatchSet
+   * @return The PatchSet specified by change_patchSet string.
+   * @throws UnloggedFailure
+   * @throws OrmException
+   */
+  private PatchSet parsePatchSet(String changePatchSet) throws UnloggedFailure,
+      OrmException {
+    if (changePatchSet.matches("^[1-9][0-9]*,[1-9][0-9]*$")) {
+      final PatchSet.Id patchSetId;
+      try {
+        patchSetId = PatchSet.Id.parse(changePatchSet);
+      } catch (IllegalArgumentException e) {
+        throw new UnloggedFailure(1, "\"" + changePatchSet
+            + "\" is not a valid patch set");
+      }
+      final PatchSet patchSet = dbProvider.get().patchSets().get(patchSetId);
+      if (patchSet == null) {
+        throw new UnloggedFailure(1, "\"" + changePatchSet
+            + "\" no such patch set");
+      }
+      return patchSet;
+    }
+    throw new UnloggedFailure(1, "\"" + changePatchSet
+        + "\" is not a valid patch set");
+  }
+
+  /**
+   * Sends message to stdout with new line.
+   *
+   * @param message
+   */
+  private void sendUserInfo(String message) {
+    stdout.print(message + "\n");
+  }
+
+  /**
+   * Gets a string representation of the Change.Status.
+   *
+   * @param changeStatus
+   * @return The name of the Change.Status.
+   */
+  private String getStatusName(Change.Status changeStatus) {
+    String statusName = changeStatus.toString().toLowerCase() + ".";
+    return statusName;
+  }
+
+  /**
+   * Sets PatchSet specified by argument as Draft if parentChange has status
+   * NEW.
+   *
+   * @return The updated PatchSet.
+   * @throws OrmException
+   */
+  private PatchSet setPatchSetAsDraft() throws OrmException {
+    final PatchSet updatedPatchSet =
+        dbProvider.get().patchSets()
+            .atomicUpdate(patchSet.getId(), new AtomicUpdate<PatchSet>() {
+              @Override
+              public PatchSet update(PatchSet patchset) {
+                patchset.setDraft(true);
+                sendUserInfo("Patch set successfully set to draft.");
+                return patchset;
+              }
+            });
+    return updatedPatchSet;
+  }
+
+  /**
+   * Returns all PatchSets in Change with Id = changeId.
+   *
+   * @param changeId
+   * @return A Iterable<PatchSet> view of all Patch sets in change.
+   * @throws OrmException
+   */
+  private Iterable<PatchSet> getPatchSetsForChange(Change.Id changeId)
+      throws OrmException {
+    return dbProvider.get().patchSets().byChange(changeId);
+  }
+
+  /**
+   * Checks if every PatchSet in Change, with Id = changeId, is drafts.
+   *
+   * @param changeId
+   * @return Returns true if all PatchSets in Change are drafts.
+   * @throws OrmException
+   */
+  private boolean isAllPatchSetsInChangeDrafts(Change.Id changeId)
+      throws OrmException {
+    boolean isAllDrafts = true;
+    Iterable<PatchSet> patchSets = getPatchSetsForChange(changeId);
+    for (PatchSet patchset : patchSets) {
+      if (!patchset.isDraft()) {
+        isAllDrafts = false;
+        break;
+      }
+    }
+    return isAllDrafts;
+  }
+
+  /**
+   * Updates parentChange to draft if every Patch set in Change is Draft.
+   *
+   * @return
+   * @throws OrmException
+   */
+  private Change updateChange() throws OrmException {
+    final Change updatedChange =
+        dbProvider.get().changes()
+            .atomicUpdate(parentChange.getId(), new AtomicUpdate<Change>() {
+              @Override
+              public Change update(Change change) {
+                boolean shouldBeDraft;
+                try {
+                  shouldBeDraft =
+                      isAllPatchSetsInChangeDrafts(parentChange.getId());
+                } catch (OrmException e) {
+                  sendUserInfo("Unable to check if every patch set in change is draft.");
+                  shouldBeDraft = false;
+                }
+                if (shouldBeDraft) {
+                  change.setStatus(Change.Status.DRAFT);
+                  sendUserInfo("Every patch set in change is draft, change set to draft.");
+                }
+                return change;
+              }
+            });
+    return updatedChange;
+  }
+  /**
+   * Updates PatchSet and, if applicable, parent Change.
+   *
+   * @throws OrmException
+   */
+  private void updatePatchSet() throws OrmException {
+    Change.Status changeStatus = parentChange.getStatus();
+    switch (changeStatus) {
+      case NEW:
+        setPatchSetAsDraft();
+        updateChange();
+        break;
+      default:
+        sendUserInfo("Unable to set patch set as draft, change is "
+            + getStatusName(changeStatus));
+        break;
+    }
+  }
+
+  private boolean isDraftWorkFlowDisabled() {
+    boolean draftsAllowed =
+        config.getBoolean(CHANGE_SECTION, ALLOW_DRAFT, true);
+    return !draftsAllowed;
+  }
+
+  @Override
+  public void run() throws UnloggedFailure, Failure, Exception {
+    if (isDraftWorkFlowDisabled()) {
+      sendUserInfo("Draft workflow disabled in gerrit.config, unable to set to draft.");
+    } else if (patchSet.isDraft()) {
+      sendUserInfo("Patch set is already draft.");
+    } else {
+      updatePatchSet();
+    }
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/forcedraft/ForceDraftSshModule.java b/src/main/java/com/googlesource/gerrit/plugins/forcedraft/ForceDraftSshModule.java
new file mode 100644
index 0000000..1870336
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/forcedraft/ForceDraftSshModule.java
@@ -0,0 +1,24 @@
+// 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.googlesource.gerrit.plugins.forcedraft;
+
+import com.google.gerrit.sshd.PluginCommandModule;
+
+public class ForceDraftSshModule extends PluginCommandModule {
+
+  @Override
+  protected void configureCommands() {
+    command(ForceDraft.class);
+  }
+}
diff --git a/src/main/resources/documentation/about.md b/src/main/resources/documentation/about.md
new file mode 100644
index 0000000..ec07db1
--- /dev/null
+++ b/src/main/resources/documentation/about.md
@@ -0,0 +1,11 @@
+This plugin enables admin-user to easily change PatchSet to draft.
+
+Sometimes Git-administrators are forced to remove PatchSets for legal,
+or other reason. Today this is done by setting the PatchSet as Draft in db.
+This procedure is troublesome and error-prone due to the human
+factor.
+This plugin enables administrators to perform the procedure with a simple
+ssh-command.
+
+If all PatchSets of parent change are drafts, parent Change is
+also set to draft.