initial commit
diff --git a/.buckconfig b/.buckconfig
new file mode 100644
index 0000000..729c54f
--- /dev/null
+++ b/.buckconfig
@@ -0,0 +1,14 @@
+[alias]
+  ci = //:ci
+  plugin = //:ci
+
+[java]
+  src_roots = java, resources
+
+[project]
+  ignore = .git
+
+[cache]
+  mode = dir
+  dir = buck-out/cache
+
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..aa42023
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,12 @@
+/target
+/.classpath
+/.project
+/.settings
+/.buckconfig.local
+/.buckd
+/buck-cache
+/buck-out
+/local.properties
+*.pyc
+.buckversion
+/bucklets
diff --git a/BUCK b/BUCK
new file mode 100644
index 0000000..a557b76
--- /dev/null
+++ b/BUCK
@@ -0,0 +1,35 @@
+include_defs('//bucklets/gerrit_plugin.bucklet')
+
+gerrit_plugin(
+  name = 'ci',
+  srcs = glob(['src/main/java/**/*.java']),
+  resources = glob(['src/main/**/*']),
+  manifest_entries = [
+    'Gerrit-PluginName: ci',
+    'Gerrit-Module: com.googlesource.gerrit.plugins.ci.GlobalModule',
+    'Gerrit-SshModule: com.googlesource.gerrit.plugins.ci.SshModule',
+    'Implementation-Title: CI plugin',
+    'Implementation-URL: https://gerrit-review.googlesource.com/#/admin/projects/plugins/ci',
+  ],
+  provided_deps = [
+    '//lib/commons:dbcp',
+    '//lib:gson',
+  ]
+)
+
+java_test(
+  name = 'ci_tests',
+  srcs = glob(['src/test/java/**/*IT.java']),
+  labels = ['ci-plugin'],
+  source_under_test = [':ci__plugin'],
+  deps = GERRIT_PLUGIN_API + GERRIT_TESTS + [
+    ':ci__plugin',
+  ],
+)
+
+java_library(
+  name = 'classpath',
+  deps = GERRIT_PLUGIN_API + GERRIT_TESTS + [
+    ':ci__plugin'
+  ],
+)
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..1e33399
--- /dev/null
+++ b/README.md
@@ -0,0 +1,75 @@
+Gerrit CI reporting and visualization plugin
+============================================
+
+This plugin allow CI system to report result outcome to Gerrit. The result are visualized on change screen. Reporting can be done through SSH commands or REST API.
+
+Database schema doesn't created automatically for now. The scripts are povided separately:
+Prebuilt artifacts 
+
+Persistense
+-----------
+
+CI data is stored in seperate database (not review DB used by Gerrit
+itself). The following database dialects are currently supported:
+
+* H2
+* MySQL
+* Oracle
+* PostgreSQL
+
+Database schema doesn't created automatically for now. The script is povided for H2 only for now.
+
+```
+CREATE TABLE PATCH_SET_VERIFICATIONS(
+VALUE SMALLINT DEFAULT 0 NOT NULL,
+GRANTED TIMESTAMP NOT NULL,
+URL VARCHAR(255),
+VERIFIER VARCHAR(255),
+COMMENT VARCHAR(255),
+CHANGE_ID INTEGER DEFAULT 0 NOT NULL,
+PATCH_SET_ID INTEGER DEFAULT 0 NOT NULL,
+CATEGORY_ID VARCHAR(255) DEFAULT '' NOT NULL,
+PRIMARY KEY(CHANGE_ID, PATCH_SET_ID, CATEGORY_ID));
+```
+
+Example for SSH command
+-----------------------
+
+```
+ssh gerritd ci verify --verification "'category=gate-horizon-pep8|value=1|url=https://ci.host.com/jobs/pep8/4711|verifier=Jenkins|comment=Non Voting'" a14825a6e9c75b68c6be486ec2b8b6fed43b8858
+```
+
+Example for REST API
+--------------------
+
+```
+curl -X POST --digest --user jow:secret --data-binary
+@post-verify.txt --header "Content-Type: application/json;
+charset=UTF-8"
+http://localhost:8080/a/changes/1/revisions/4d5fda7e653534b1709883d96264910fab03ddbb/verify
+
+$ cat post-verify.txt
+{
+  "verifications": {
+    "gate-puma-pep8": {
+      "value": -1,
+      "url": "https://ci.host.com/jobs/pep8/1711",
+      "comment": "Failed",
+      "verifier": "Jenkins"
+    }
+  }
+}
+
+```
+
+TODO
+----
+
+* Documentation
+* Schema initialization and upgrade
+* UI integration
+
+License
+-------
+
+Apache License 2.0
diff --git a/src/main/java/com/googlesource/gerrit/plugins/ci/GlobalModule.java b/src/main/java/com/googlesource/gerrit/plugins/ci/GlobalModule.java
new file mode 100644
index 0000000..4dcb816
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/ci/GlobalModule.java
@@ -0,0 +1,78 @@
+// 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.googlesource.gerrit.plugins.ci;
+
+import static com.google.inject.Scopes.SINGLETON;
+
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Injector;
+import com.google.inject.Key;
+import com.google.inject.Module;
+import com.google.inject.name.Names;
+
+import com.googlesource.gerrit.plugins.ci.server.schema.CiDataSourceModule;
+import com.googlesource.gerrit.plugins.ci.server.schema.CiDataSourceProvider;
+import com.googlesource.gerrit.plugins.ci.server.schema.CiDataSourceType;
+import com.googlesource.gerrit.plugins.ci.server.schema.CiDataSourceTypeGuesser;
+import com.googlesource.gerrit.plugins.ci.server.schema.CiDatabaseModule;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import javax.sql.DataSource;
+
+public class GlobalModule extends AbstractModule {
+
+  private final  Injector injector;
+
+  @Inject
+  GlobalModule(Injector injector) {
+    this.injector = injector;
+  }
+
+  @Override
+  protected void configure() {
+    List<Module> modules = new ArrayList<>();
+    modules.add(new LifecycleModule() {
+      @Override
+      protected void configure() {
+        // For bootstrap we need to retrieve the ds type first
+        CiDataSourceTypeGuesser guesser =
+            injector.createChildInjector(
+                new CiDataSourceModule()).getInstance(
+                    Key.get(CiDataSourceTypeGuesser.class));
+
+        // For the ds type we retrieve the underlying implementation
+        CiDataSourceType dst = injector.createChildInjector(
+            new CiDataSourceModule()).getInstance(
+                Key.get(CiDataSourceType.class,
+                    Names.named(guesser.guessDataSourceType())));
+        // Bind the type to the retrieved instance
+        bind(CiDataSourceType.class).toInstance(dst);
+        bind(CiDataSourceProvider.Context.class).toInstance(
+            CiDataSourceProvider.Context.MULTI_USER);
+        bind(Key.get(DataSource.class, Names.named("CiDb"))).toProvider(
+            CiDataSourceProvider.class).in(SINGLETON);
+        listener().to(CiDataSourceProvider.class);
+      }
+    });
+    modules.add(new CiDatabaseModule());
+    for (Module module : modules) {
+      install(module);
+    }
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/ci/InitPlugin.java b/src/main/java/com/googlesource/gerrit/plugins/ci/InitPlugin.java
new file mode 100644
index 0000000..8ce45a3
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/ci/InitPlugin.java
@@ -0,0 +1,40 @@
+// 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.googlesource.gerrit.plugins.ci;
+
+import com.google.gerrit.pgm.init.api.InitStep;
+import com.google.inject.Inject;
+
+import com.googlesource.gerrit.plugins.ci.server.CiDb;
+
+public class InitPlugin implements InitStep {
+
+  // TODO(davido): Add site initialization logic
+  @SuppressWarnings("unused")
+  private final CiDb ciDb;
+
+  @Inject
+  InitPlugin(CiDb ciDb) {
+    this.ciDb = ciDb;
+  }
+
+  @Override
+  public void run() throws Exception {
+  }
+
+  @Override
+  public void postRun() throws Exception {
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/ci/SshModule.java b/src/main/java/com/googlesource/gerrit/plugins/ci/SshModule.java
new file mode 100644
index 0000000..2d7d16c
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/ci/SshModule.java
@@ -0,0 +1,30 @@
+// 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.googlesource.gerrit.plugins.ci;
+
+import com.google.gerrit.sshd.PluginCommandModule;
+
+import com.googlesource.gerrit.plugins.ci.commands.CiAdminQueryShell;
+import com.googlesource.gerrit.plugins.ci.commands.CiQueryShell;
+import com.googlesource.gerrit.plugins.ci.commands.VerifyCommand;
+
+public class SshModule extends PluginCommandModule {
+  @Override
+  protected void configureCommands() {
+    command(CiAdminQueryShell.class);
+    command(VerifyCommand.class);
+    factory(CiQueryShell.Factory.class);
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/ci/commands/CiAdminQueryShell.java b/src/main/java/com/googlesource/gerrit/plugins/ci/commands/CiAdminQueryShell.java
new file mode 100644
index 0000000..74636c9
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/ci/commands/CiAdminQueryShell.java
@@ -0,0 +1,79 @@
+// 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.googlesource.gerrit.plugins.ci.commands;
+
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.common.errors.PermissionDeniedException;
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.sshd.AdminHighPriorityCommand;
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.inject.Inject;
+
+import org.kohsuke.args4j.Option;
+
+/** Opens a query processor. */
+@AdminHighPriorityCommand
+@RequiresCapability(GlobalCapability.ACCESS_DATABASE)
+@CommandMetaData(name = "cisql", description = "Administrative interface to CI database")
+public class CiAdminQueryShell extends SshCommand {
+  @Inject
+  private CiQueryShell.Factory factory;
+
+  @Inject
+  private IdentifiedUser currentUser;
+
+  @Option(name = "--format", usage = "Set output format")
+  private CiQueryShell.OutputFormat format = CiQueryShell.OutputFormat.PRETTY;
+
+  @Option(name = "-c", metaVar = "SQL QUERY", usage = "Query to execute")
+  private String query;
+
+  @Override
+  protected void run() throws Failure {
+    try {
+      checkPermission();
+
+      final CiQueryShell shell = factory.create(in, out);
+      shell.setOutputFormat(format);
+      if (query != null) {
+        shell.execute(query);
+      } else {
+        shell.run();
+      }
+    } catch (PermissionDeniedException err) {
+      throw new UnloggedFailure("fatal: " + err.getMessage());
+    }
+  }
+
+  /**
+   * Assert that the current user is permitted to perform raw queries.
+   * <p>
+   * As the @RequireCapability guards at various entry points of internal
+   * commands implicitly add administrators (which we want to avoid), we also
+   * check permissions within QueryShell and grant access only to those who
+   * canPerformRawQuery, regardless of whether they are administrators or not.
+   *
+   * @throws PermissionDeniedException
+   */
+  private void checkPermission() throws PermissionDeniedException {
+    if (!currentUser.getCapabilities().canAccessDatabase()) {
+      throw new PermissionDeniedException(String.format(
+          "%s does not have \"Access Database\" capability.",
+          currentUser.getUserName()));
+    }
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/ci/commands/CiQueryShell.java b/src/main/java/com/googlesource/gerrit/plugins/ci/commands/CiQueryShell.java
new file mode 100644
index 0000000..1c4dd45
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/ci/commands/CiQueryShell.java
@@ -0,0 +1,771 @@
+// 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.googlesource.gerrit.plugins.ci.commands;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.common.Version;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+import com.google.gwtorm.jdbc.JdbcSchema;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+import com.googlesource.gerrit.plugins.ci.server.CiDb;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.io.PrintWriter;
+import java.sql.Connection;
+import java.sql.DatabaseMetaData;
+import java.sql.ResultSet;
+import java.sql.ResultSetMetaData;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+
+/** Simple interactive SQL query tool. */
+public class CiQueryShell {
+  public interface Factory {
+    CiQueryShell create(@Assisted InputStream in, @Assisted OutputStream out);
+  }
+
+  public static enum OutputFormat {
+    PRETTY, JSON, JSON_SINGLE
+  }
+
+  private final BufferedReader in;
+  private final PrintWriter out;
+  private final SchemaFactory<CiDb> dbFactory;
+  private OutputFormat outputFormat = OutputFormat.PRETTY;
+
+  private CiDb db;
+  private Connection connection;
+  private Statement statement;
+
+  @Inject
+  CiQueryShell(SchemaFactory<CiDb> dbFactory,
+      @Assisted InputStream in,
+      @Assisted OutputStream out) {
+    this.dbFactory = dbFactory;
+    this.in = new BufferedReader(new InputStreamReader(in, UTF_8));
+    this.out = new PrintWriter(new OutputStreamWriter(out, UTF_8));
+  }
+
+  public void setOutputFormat(OutputFormat fmt) {
+    outputFormat = fmt;
+  }
+
+  public void run() {
+    try {
+      db = dbFactory.open();
+      try {
+        connection = ((JdbcSchema) db).getConnection();
+        connection.setAutoCommit(true);
+
+        statement = connection.createStatement();
+        try {
+          showBanner();
+          readEvalPrintLoop();
+        } finally {
+          statement.close();
+          statement = null;
+        }
+      } finally {
+        db.close();
+        db = null;
+      }
+    } catch (OrmException | SQLException err) {
+      out.println("fatal: Cannot open connection: " + err.getMessage());
+    } finally {
+      out.flush();
+    }
+  }
+
+  public void execute(String query) {
+    try {
+      db = dbFactory.open();
+      try {
+        connection = ((JdbcSchema) db).getConnection();
+        connection.setAutoCommit(true);
+
+        statement = connection.createStatement();
+        try {
+          executeStatement(query);
+        } finally {
+          statement.close();
+          statement = null;
+        }
+      } finally {
+        db.close();
+        db = null;
+      }
+    } catch (OrmException | SQLException err) {
+      out.println("fatal: Cannot open connection: " + err.getMessage());
+    } finally {
+      out.flush();
+    }
+  }
+
+  private void readEvalPrintLoop() {
+    final StringBuilder buffer = new StringBuilder();
+    boolean executed = false;
+    for (;;) {
+      if (outputFormat == OutputFormat.PRETTY) {
+        print(buffer.length() == 0 || executed ? "gerrit> " : "     -> ");
+      }
+      String line = readLine();
+      if (line == null) {
+        return;
+      }
+
+      if (line.startsWith("\\")) {
+        // Shell command, check the various cases we recognize
+        //
+        line = line.substring(1);
+        if (line.equals("h") || line.equals("?")) {
+          showHelp();
+
+        } else if (line.equals("q")) {
+          if (outputFormat == OutputFormat.PRETTY) {
+            println("Bye");
+          }
+          return;
+
+        } else if (line.equals("r")) {
+          buffer.setLength(0);
+          executed = false;
+
+        } else if (line.equals("p")) {
+          println(buffer.toString());
+
+        } else if (line.equals("g")) {
+          if (buffer.length() > 0) {
+            executeStatement(buffer.toString());
+            executed = true;
+          }
+
+        } else if (line.equals("d")) {
+          listTables();
+
+        } else if (line.startsWith("d ")) {
+          showTable(line.substring(2).trim());
+
+        } else {
+          final String msg = "'\\" + line + "' not supported";
+          switch (outputFormat) {
+            case JSON_SINGLE:
+            case JSON: {
+              final JsonObject err = new JsonObject();
+              err.addProperty("type", "error");
+              err.addProperty("message", msg);
+              println(err.toString());
+              break;
+            }
+            case PRETTY:
+            default:
+              println("ERROR: " + msg);
+              println("");
+              showHelp();
+              break;
+          }
+        }
+        continue;
+      }
+
+      if (executed) {
+        buffer.setLength(0);
+        executed = false;
+      }
+      if (buffer.length() > 0) {
+        buffer.append('\n');
+      }
+      buffer.append(line);
+
+      if (buffer.length() > 0 && buffer.charAt(buffer.length() - 1) == ';') {
+        executeStatement(buffer.toString());
+        executed = true;
+      }
+    }
+  }
+
+  private void listTables() {
+    final DatabaseMetaData meta;
+    try {
+      meta = connection.getMetaData();
+    } catch (SQLException e) {
+      error(e);
+      return;
+    }
+
+    final String[] types = {"TABLE", "VIEW"};
+    try (ResultSet rs = meta.getTables(null, null, null, types)) {
+      if (outputFormat == OutputFormat.PRETTY) {
+        println("                     List of relations");
+      }
+      showResultSet(rs, false, 0,
+          Identity.create(rs, "TABLE_SCHEM"),
+          Identity.create(rs, "TABLE_NAME"),
+          Identity.create(rs, "TABLE_TYPE"));
+    } catch (SQLException e) {
+      error(e);
+    }
+
+    println("");
+  }
+
+  private void showTable(String tableName) {
+    final DatabaseMetaData meta;
+    try {
+      meta = connection.getMetaData();
+
+      if (meta.storesUpperCaseIdentifiers()) {
+        tableName = tableName.toUpperCase();
+      } else if (meta.storesLowerCaseIdentifiers()) {
+        tableName = tableName.toLowerCase();
+      }
+    } catch (SQLException e) {
+      error(e);
+      return;
+    }
+
+    try (ResultSet rs = meta.getColumns(null, null, tableName, null)) {
+      if (!rs.next()) {
+        throw new SQLException("Table " + tableName + " not found");
+      }
+
+      if (outputFormat == OutputFormat.PRETTY) {
+        println("                     Table " + tableName);
+      }
+      showResultSet(rs, true, 0,
+          Identity.create(rs, "COLUMN_NAME"),
+          new Function("TYPE") {
+            @Override
+            String apply(final ResultSet rs) throws SQLException {
+              String type = rs.getString("TYPE_NAME");
+              switch (rs.getInt("DATA_TYPE")) {
+                case java.sql.Types.CHAR:
+                case java.sql.Types.VARCHAR:
+                  type += "(" + rs.getInt("COLUMN_SIZE") + ")";
+                  break;
+              }
+
+              String def = rs.getString("COLUMN_DEF");
+              if (def != null && !def.isEmpty()) {
+                type += " DEFAULT " + def;
+              }
+
+              int nullable = rs.getInt("NULLABLE");
+              if (nullable == DatabaseMetaData.columnNoNulls) {
+                type += " NOT NULL";
+              }
+              return type;
+            }
+          });
+    } catch (SQLException e) {
+      error(e);
+      return;
+    }
+
+    try (ResultSet rs = meta.getIndexInfo(null, null, tableName, false, true)) {
+      Map<String, IndexInfo> indexes = new TreeMap<>();
+      while (rs.next()) {
+        final String indexName = rs.getString("INDEX_NAME");
+        IndexInfo def = indexes.get(indexName);
+        if (def == null) {
+          def = new IndexInfo();
+          def.name = indexName;
+          indexes.put(indexName, def);
+        }
+
+        if (!rs.getBoolean("NON_UNIQUE")) {
+          def.unique = true;
+        }
+
+        final int pos = rs.getInt("ORDINAL_POSITION");
+        final String col = rs.getString("COLUMN_NAME");
+        String desc = rs.getString("ASC_OR_DESC");
+        if ("D".equals(desc)) {
+          desc = " DESC";
+        } else {
+          desc = "";
+        }
+        def.addColumn(pos, col + desc);
+
+        String filter = rs.getString("FILTER_CONDITION");
+        if (filter != null && !filter.isEmpty()) {
+          def.filter.append(filter);
+        }
+      }
+
+      if (outputFormat == OutputFormat.PRETTY) {
+        println("");
+        println("Indexes on " + tableName + ":");
+        for (IndexInfo def : indexes.values()) {
+          println("  " + def);
+        }
+      }
+    } catch (SQLException e) {
+      error(e);
+      return;
+    }
+
+    println("");
+  }
+
+  private void executeStatement(final String sql) {
+    final long start = TimeUtil.nowMs();
+    final boolean hasResultSet;
+    try {
+      hasResultSet = statement.execute(sql);
+    } catch (SQLException e) {
+      error(e);
+      return;
+    }
+
+    try {
+      if (hasResultSet) {
+        try (ResultSet rs = statement.getResultSet()) {
+          showResultSet(rs, false, start);
+        }
+
+      } else {
+        final int updateCount = statement.getUpdateCount();
+        final long ms = TimeUtil.nowMs() - start;
+        switch (outputFormat) {
+          case JSON_SINGLE:
+          case JSON: {
+            final JsonObject tail = new JsonObject();
+            tail.addProperty("type", "update-stats");
+            tail.addProperty("rowCount", updateCount);
+            tail.addProperty("runTimeMilliseconds", ms);
+            println(tail.toString());
+            break;
+          }
+
+          case PRETTY:
+          default:
+            println("UPDATE " + updateCount + "; " + ms + " ms");
+            break;
+        }
+      }
+    } catch (SQLException e) {
+      error(e);
+    }
+  }
+
+  /**
+   * Outputs a result set to stdout.
+   *
+   * @param rs ResultSet to show.
+   * @param alreadyOnRow true if rs is already on the first row. false
+   *     otherwise.
+   * @param start Timestamp in milliseconds when executing the statement
+   *     started. This timestamp is used to compute statistics about the
+   *     statement. If no statistics should be shown, set it to 0.
+   * @param show Functions to map columns
+   * @throws SQLException
+   */
+  private void showResultSet(final ResultSet rs, boolean alreadyOnRow,
+      long start, Function... show) throws SQLException {
+    switch (outputFormat) {
+      case JSON_SINGLE:
+      case JSON:
+        showResultSetJson(rs, alreadyOnRow,  start, show);
+        break;
+      case PRETTY:
+      default:
+        showResultSetPretty(rs, alreadyOnRow, start, show);
+        break;
+    }
+  }
+
+  /**
+   * Outputs a result set to stdout in Json format.
+   *
+   * @param rs ResultSet to show.
+   * @param alreadyOnRow true if rs is already on the first row. false
+   *     otherwise.
+   * @param start Timestamp in milliseconds when executing the statement
+   *     started. This timestamp is used to compute statistics about the
+   *     statement. If no statistics should be shown, set it to 0.
+   * @param show Functions to map columns
+   * @throws SQLException
+   */
+  private void showResultSetJson(final ResultSet rs, boolean alreadyOnRow,
+      long start, Function... show) throws SQLException {
+    JsonArray collector = new JsonArray();
+    final ResultSetMetaData meta = rs.getMetaData();
+    final Function[] columnMap;
+    if (show != null && 0 < show.length) {
+      columnMap = show;
+
+    } else {
+      final int colCnt = meta.getColumnCount();
+      columnMap = new Function[colCnt];
+      for (int colId = 0; colId < colCnt; colId++) {
+        final int p = colId + 1;
+        final String name = meta.getColumnLabel(p);
+        columnMap[colId] = new Identity(p, name);
+      }
+    }
+
+    int rowCnt = 0;
+    while (alreadyOnRow || rs.next()) {
+      final JsonObject row = new JsonObject();
+      final JsonObject cols = new JsonObject();
+      for (Function function : columnMap) {
+        String v = function.apply(rs);
+        if (v == null) {
+          continue;
+        }
+        cols.addProperty(function.name.toLowerCase(), v);
+      }
+      row.addProperty("type", "row");
+      row.add("columns", cols);
+      switch (outputFormat) {
+        case JSON:
+          println(row.toString());
+          break;
+        case JSON_SINGLE:
+          collector.add(row);
+          break;
+        default:
+          final JsonObject obj = new JsonObject();
+          obj.addProperty("type", "error");
+          obj.addProperty("message", "Unsupported Json variant");
+          println(obj.toString());
+          return;
+      }
+      alreadyOnRow = false;
+      rowCnt++;
+    }
+
+    JsonObject tail = null;
+    if (start != 0) {
+      tail = new JsonObject();
+      tail.addProperty("type", "query-stats");
+      tail.addProperty("rowCount", rowCnt);
+      final long ms = TimeUtil.nowMs() - start;
+      tail.addProperty("runTimeMilliseconds", ms);
+    }
+
+    switch (outputFormat) {
+      case JSON:
+        if (tail != null) {
+          println(tail.toString());
+        }
+        break;
+      case JSON_SINGLE:
+        if (tail != null) {
+          collector.add(tail);
+        }
+        println(collector.toString());
+        break;
+      default:
+        final JsonObject obj = new JsonObject();
+        obj.addProperty("type", "error");
+        obj.addProperty("message", "Unsupported Json variant");
+        println(obj.toString());
+    }
+  }
+
+  /**
+   * Outputs a result set to stdout in plain text format.
+   *
+   * @param rs ResultSet to show.
+   * @param alreadyOnRow true if rs is already on the first row. false
+   *     otherwise.
+   * @param start Timestamp in milliseconds when executing the statement
+   *     started. This timestamp is used to compute statistics about the
+   *     statement. If no statistics should be shown, set it to 0.
+   * @param show Functions to map columns
+   * @throws SQLException
+   */
+  private void showResultSetPretty(final ResultSet rs, boolean alreadyOnRow,
+      long start, Function... show) throws SQLException {
+    final ResultSetMetaData meta = rs.getMetaData();
+
+    final Function[] columnMap;
+    if (show != null && 0 < show.length) {
+      columnMap = show;
+
+    } else {
+      final int colCnt = meta.getColumnCount();
+      columnMap = new Function[colCnt];
+      for (int colId = 0; colId < colCnt; colId++) {
+        final int p = colId + 1;
+        final String name = meta.getColumnLabel(p);
+        columnMap[colId] = new Identity(p, name);
+      }
+    }
+
+    final int colCnt = columnMap.length;
+    final int[] widths = new int[colCnt];
+    for (int c = 0; c < colCnt; c++) {
+      widths[c] = columnMap[c].name.length();
+    }
+
+    final List<String[]> rows = new ArrayList<>();
+    while (alreadyOnRow || rs.next()) {
+      final String[] row = new String[columnMap.length];
+      for (int c = 0; c < colCnt; c++) {
+        row[c] = columnMap[c].apply(rs);
+        if (row[c] == null) {
+          row[c] = "NULL";
+        }
+        widths[c] = Math.max(widths[c], row[c].length());
+      }
+      rows.add(row);
+      alreadyOnRow = false;
+    }
+
+    final StringBuilder b = new StringBuilder();
+    for (int c = 0; c < colCnt; c++) {
+      if (0 < c) {
+        b.append(" | ");
+      }
+
+      String n = columnMap[c].name;
+      if (widths[c] < n.length()) {
+        n = n.substring(0, widths[c]);
+      }
+      b.append(n);
+
+      if (c < colCnt - 1) {
+        for (int pad = n.length(); pad < widths[c]; pad++) {
+          b.append(' ');
+        }
+      }
+    }
+    println(" " + b.toString());
+
+    b.setLength(0);
+    for (int c = 0; c < colCnt; c++) {
+      if (0 < c) {
+        b.append("-+-");
+      }
+      for (int pad = 0; pad < widths[c]; pad++) {
+        b.append('-');
+      }
+    }
+    println(" " + b.toString());
+
+    boolean dataTruncated = false;
+    for (String[] row : rows) {
+      b.setLength(0);
+      b.append(' ');
+
+      for (int c = 0; c < colCnt; c++) {
+        final int max = widths[c];
+        if (0 < c) {
+          b.append(" | ");
+        }
+
+        String s = row[c];
+        if (1 < colCnt && max < s.length()) {
+          s = s.substring(0, max);
+          dataTruncated = true;
+        }
+        b.append(s);
+
+        if (c < colCnt - 1) {
+          for (int pad = s.length(); pad < max; pad++) {
+            b.append(' ');
+          }
+        }
+      }
+      println(b.toString());
+    }
+
+    if (dataTruncated) {
+      warning("some column data was truncated");
+    }
+
+    if (start != 0) {
+      final int rowCount = rows.size();
+      final long ms = TimeUtil.nowMs() - start;
+      println("(" + rowCount + (rowCount == 1 ? " row" : " rows")
+          + "; " + ms + " ms)");
+    }
+  }
+
+  private void warning(final String msg) {
+    switch (outputFormat) {
+      case JSON_SINGLE:
+      case JSON: {
+        final JsonObject obj = new JsonObject();
+        obj.addProperty("type", "warning");
+        obj.addProperty("message", msg);
+        println(obj.toString());
+        break;
+      }
+
+      case PRETTY:
+      default:
+        println("WARNING: " + msg);
+        break;
+    }
+  }
+
+  private void error(final SQLException err) {
+    switch (outputFormat) {
+      case JSON_SINGLE:
+      case JSON: {
+        final JsonObject obj = new JsonObject();
+        obj.addProperty("type", "error");
+        obj.addProperty("message", err.getMessage());
+        println(obj.toString());
+        break;
+      }
+
+      case PRETTY:
+      default:
+        println("ERROR: " + err.getMessage());
+        break;
+    }
+  }
+
+  private void print(String s) {
+    out.print(s);
+    out.flush();
+  }
+
+  private void println(String s) {
+    out.print(s);
+    out.print('\n');
+    out.flush();
+  }
+
+  private String readLine() {
+    try {
+      return in.readLine();
+    } catch (IOException e) {
+      return null;
+    }
+  }
+
+  private void showBanner() {
+    if (outputFormat == OutputFormat.PRETTY) {
+      println("Welcome to Gerrit Code Review " + Version.getVersion());
+      try {
+        print("(");
+        print(connection.getMetaData().getDatabaseProductName());
+        print(" ");
+        print(connection.getMetaData().getDatabaseProductVersion());
+        println(")");
+      } catch (SQLException err) {
+        error(err);
+      }
+      println("");
+      println("Type '\\h' for help.  Type '\\r' to clear the buffer.");
+      println("");
+    }
+  }
+
+  private void showHelp() {
+    final StringBuilder help = new StringBuilder();
+    help.append("General\n");
+    help.append("  \\q        quit\n");
+
+    help.append("\n");
+    help.append("Query Buffer\n");
+    help.append("  \\g        execute the query buffer\n");
+    help.append("  \\p        display the current buffer\n");
+    help.append("  \\r        clear the query buffer\n");
+
+    help.append("\n");
+    help.append("Informational\n");
+    help.append("  \\d        list all tables\n");
+    help.append("  \\d NAME   describe table\n");
+
+    help.append("\n");
+    print(help.toString());
+  }
+
+  private abstract static class Function {
+    final String name;
+
+    Function(final String name) {
+      this.name = name;
+    }
+
+    abstract String apply(ResultSet rs) throws SQLException;
+  }
+
+  private static class Identity extends Function {
+    static Identity create(final ResultSet rs, final String name)
+        throws SQLException {
+      return new Identity(rs.findColumn(name), name);
+    }
+
+    final int colId;
+
+    Identity(final int colId, final String name) {
+      super(name);
+      this.colId = colId;
+    }
+
+    @Override
+    String apply(final ResultSet rs) throws SQLException {
+      return rs.getString(colId);
+    }
+  }
+
+  private static class IndexInfo {
+    String name;
+    boolean unique;
+    final Map<Integer, String> columns = new TreeMap<>();
+    final StringBuilder filter = new StringBuilder();
+
+    void addColumn(int pos, String column) {
+      columns.put(Integer.valueOf(pos), column);
+    }
+
+    @Override
+    public String toString() {
+      final StringBuilder r = new StringBuilder();
+      r.append(name);
+      if (unique) {
+        r.append(" UNIQUE");
+      }
+      r.append(" (");
+      boolean first = true;
+      for (String c : columns.values()) {
+        if (!first) {
+          r.append(", ");
+        }
+        r.append(c);
+        first = false;
+      }
+      r.append(")");
+      if (filter.length() > 0) {
+        r.append(" WHERE ");
+        r.append(filter);
+      }
+      return r.toString();
+    }
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/ci/commands/VerifyCommand.java b/src/main/java/com/googlesource/gerrit/plugins/ci/commands/VerifyCommand.java
new file mode 100644
index 0000000..9b680ac
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/ci/commands/VerifyCommand.java
@@ -0,0 +1,170 @@
+// 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.googlesource.gerrit.plugins.ci.commands;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.common.base.Splitter;
+import com.google.common.base.Strings;
+import com.google.common.collect.Maps;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.project.ProjectControl;
+import com.google.gerrit.sshd.CommandMetaData;
+import com.google.gerrit.sshd.SshCommand;
+import com.google.gerrit.sshd.commands.CommandUtils;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+
+import com.googlesource.gerrit.plugins.ci.common.VerificationInfo;
+import com.googlesource.gerrit.plugins.ci.common.VerifyInput;
+import com.googlesource.gerrit.plugins.ci.server.PostVerification;
+
+import org.kohsuke.args4j.Argument;
+import org.kohsuke.args4j.Option;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+@CommandMetaData(name = "verify", description = "Verify one or more patch sets")
+public class VerifyCommand extends SshCommand {
+  private static final Logger log =
+      LoggerFactory.getLogger(VerifyCommand.class);
+
+  private final Set<PatchSet> patchSets = new HashSet<>();
+
+  @Argument(index = 0, required = true, multiValued = true,
+      metaVar = "{COMMIT | CHANGE,PATCHSET}",
+      usage = "list of commits or patch sets to verify")
+  void addPatchSetId(String token) {
+    try {
+      PatchSet ps = CommandUtils.parsePatchSet(token, db, projectControl,
+          branch);
+      patchSets.add(ps);
+    } catch (UnloggedFailure e) {
+      throw new IllegalArgumentException(e.getMessage(), e);
+    } catch (OrmException e) {
+      throw new IllegalArgumentException("database error", e);
+    }
+  }
+
+  @Option(name = "--project", aliases = "-p",
+      usage = "project containing the specified patch set(s)")
+  private ProjectControl projectControl;
+
+  @Option(name = "--branch", aliases = "-b",
+      usage = "branch containing the specified patch set(s)")
+  private String branch;
+
+  @Option(name = "--verification", aliases = "-v",
+      usage = "verification to set the result for", metaVar = "VERIFY=OUTCOME")
+  void addJob(String token) {
+    parseWithEquals(token);
+  }
+
+  private void parseWithEquals(String text) {
+    log.debug("processing verification: " + text);
+    checkArgument(!Strings.isNullOrEmpty(text), "Empty verification vote");
+    Map<String, String> params = null;
+    try {
+      params = Splitter.on("|").withKeyValueSeparator("=").split(text);
+    } catch (IllegalArgumentException e) {
+      throw new IllegalArgumentException(String.valueOf("Invalid verification parameters"));
+    }
+
+    String category = params.get("category");
+    checkArgument(category != null, "Verification is missing a category");
+    String value = params.get("value");
+    checkArgument(value != null, "Verification is missing a value");
+    VerificationInfo data = new VerificationInfo();
+    data.value = Short.parseShort(value);
+    data.url = params.get("url");
+    data.verifier = params.get("verifier");
+    data.comment = params.get("comment");
+    jobResult.put(category, data);
+  }
+
+  @Inject
+  private ReviewDb db;
+
+  @Inject
+  private IdentifiedUser currentUser;
+
+  @Inject
+  private PostVerification postVerification;
+
+  @Inject
+  private ChangeControl.GenericFactory genericFactory;
+
+  private Map<String, VerificationInfo> jobResult = Maps.newHashMap();
+
+  @Override
+  protected void run() throws UnloggedFailure {
+    boolean ok = true;
+    for (PatchSet patchSet : patchSets) {
+      try {
+        verifyOne(patchSet);
+      } catch (UnloggedFailure e) {
+        ok = false;
+        writeError("error: " + e.getMessage() + "\n");
+      }
+    }
+
+    if (!ok) {
+      throw new UnloggedFailure(1, "one or more verifications failed;"
+          + " review output above");
+    }
+  }
+
+  private void applyVerification(PatchSet patchSet, VerifyInput verify)
+      throws RestApiException, NoSuchChangeException, OrmException,
+      IOException {
+    ChangeControl ctl =
+        genericFactory.controlFor(patchSet.getId().getParentKey(),
+            currentUser);
+    ChangeResource changeResource = new ChangeResource(ctl);
+    RevisionResource revResource = new RevisionResource(changeResource,
+        patchSet);
+    postVerification.apply(revResource, verify);
+  }
+
+  private void verifyOne(PatchSet patchSet) throws UnloggedFailure {
+    VerifyInput verify = new VerifyInput();
+    verify.verifications = jobResult;
+    try {
+      applyVerification(patchSet, verify);
+    } catch (RestApiException | NoSuchChangeException | OrmException
+        | IOException e) {
+      throw CommandUtils.error(e.getMessage());
+    }
+  }
+
+  private void writeError(String msg) {
+    try {
+      err.write(msg.getBytes(ENC));
+    } catch (IOException e) {
+    }
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/ci/common/VerificationInfo.java b/src/main/java/com/googlesource/gerrit/plugins/ci/common/VerificationInfo.java
new file mode 100644
index 0000000..58a05c7
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/ci/common/VerificationInfo.java
@@ -0,0 +1,22 @@
+// 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.googlesource.gerrit.plugins.ci.common;
+
+public class VerificationInfo {
+  public String url;
+  public Short value;
+  public String verifier;
+  public String comment;
+}
\ No newline at end of file
diff --git a/src/main/java/com/googlesource/gerrit/plugins/ci/common/VerifyInput.java b/src/main/java/com/googlesource/gerrit/plugins/ci/common/VerifyInput.java
new file mode 100644
index 0000000..710d1d4
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/ci/common/VerifyInput.java
@@ -0,0 +1,24 @@
+// 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.googlesource.gerrit.plugins.ci.common;
+
+import com.google.gerrit.extensions.restapi.DefaultInput;
+
+import java.util.Map;
+
+public class VerifyInput {
+  @DefaultInput
+  public Map<String, VerificationInfo> verifications;
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/ci/server/CiDb.java b/src/main/java/com/googlesource/gerrit/plugins/ci/server/CiDb.java
new file mode 100644
index 0000000..a2dfac9
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/ci/server/CiDb.java
@@ -0,0 +1,28 @@
+// 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.googlesource.gerrit.plugins.ci.server;
+
+import com.google.gerrit.reviewdb.server.SchemaVersionAccess;
+import com.google.gwtorm.server.Relation;
+import com.google.gwtorm.server.Schema;
+
+public interface CiDb extends Schema {
+
+  @Relation(id = 1)
+  SchemaVersionAccess schemaVersion();
+
+  @Relation(id = 2)
+  PatchSetVerificationAccess patchSetVerifications();
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/ci/server/GetVerifications.java b/src/main/java/com/googlesource/gerrit/plugins/ci/server/GetVerifications.java
new file mode 100644
index 0000000..efff050
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/ci/server/GetVerifications.java
@@ -0,0 +1,56 @@
+// 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.googlesource.gerrit.plugins.ci.server;
+
+import com.google.common.collect.Maps;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+import com.googlesource.gerrit.plugins.ci.common.VerificationInfo;
+
+import java.io.IOException;
+import java.util.Map;
+
+@Singleton
+public class GetVerifications implements RestReadView<RevisionResource> {
+  private final SchemaFactory<CiDb> schemaFactory;
+
+  @Inject
+  GetVerifications(SchemaFactory<CiDb> schemaFactory) {
+    this.schemaFactory = schemaFactory;
+  }
+
+  @Override
+  public Map<String, VerificationInfo> apply(RevisionResource rsrc)
+      throws IOException, OrmException {
+    Map<String, VerificationInfo> out = Maps.newHashMap();
+    try (CiDb db = schemaFactory.open()) {
+      for (PatchSetVerification v : db.patchSetVerifications()
+          .byPatchSet(rsrc.getPatchSet().getId())) {
+        VerificationInfo info = new VerificationInfo();
+        info.value = v.getValue();
+        info.url = v.getUrl();
+        info.verifier = v.getVerifier();
+        info.comment = v.getComment();
+        out.put(v.getLabelId().get(), info);
+      }
+    }
+    return out;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/ci/server/PatchSetVerification.java b/src/main/java/com/googlesource/gerrit/plugins/ci/server/PatchSetVerification.java
new file mode 100644
index 0000000..1c9ab59
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/ci/server/PatchSetVerification.java
@@ -0,0 +1,166 @@
+// 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.googlesource.gerrit.plugins.ci.server;
+
+import java.sql.Timestamp;
+import java.util.Objects;
+
+import com.google.gerrit.reviewdb.client.LabelId;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gwtorm.client.Column;
+import com.google.gwtorm.client.CompoundKey;
+
+public class PatchSetVerification {
+
+  public static class Key extends CompoundKey<PatchSet.Id> {
+    private static final long serialVersionUID = 1L;
+
+    @Column(id = 1, name = Column.NONE)
+    protected PatchSet.Id patchSetId;
+
+    @Column(id = 2)
+    protected LabelId categoryId;
+
+    protected Key() {
+      patchSetId = new PatchSet.Id();
+      categoryId = new LabelId();
+    }
+
+    public Key(PatchSet.Id ps, LabelId c) {
+      this.patchSetId = ps;
+      this.categoryId = c;
+    }
+
+    @Override
+    public PatchSet.Id getParentKey() {
+      return patchSetId;
+    }
+
+    public LabelId getLabelId() {
+      return categoryId;
+    }
+
+    @Override
+    public com.google.gwtorm.client.Key<?>[] members() {
+      return new com.google.gwtorm.client.Key<?>[] {categoryId};
+    }
+  }
+
+  @Column(id = 1, name = Column.NONE)
+  protected Key key;
+
+  @Column(id = 2)
+  protected short value;
+
+  @Column(id = 3)
+  protected Timestamp granted;
+
+  @Column(id = 4, notNull = false, length = 255)
+  protected String url;
+
+  @Column(id = 5, notNull = false, length = 255)
+  protected String verifier;
+
+  @Column(id = 6, notNull = false, length = 255)
+  protected String comment;
+
+  protected PatchSetVerification() {
+  }
+
+  public PatchSetVerification(PatchSetVerification.Key k, short v,
+      Timestamp ts) {
+    key = k;
+    setValue(v);
+    setGranted(ts);
+  }
+
+  public PatchSetVerification.Key getKey() {
+    return key;
+  }
+
+  public PatchSet.Id getPatchSetId() {
+    return key.patchSetId;
+  }
+
+  public LabelId getLabelId() {
+    return key.categoryId;
+  }
+
+  public short getValue() {
+    return value;
+  }
+
+  public void setValue(short v) {
+    value = v;
+  }
+
+  public Timestamp getGranted() {
+    return granted;
+  }
+
+  public void setGranted(Timestamp ts) {
+    granted = ts;
+  }
+
+  public String getLabel() {
+    return getLabelId().get();
+  }
+
+  public String getUrl() {
+    return url;
+  }
+
+  public void setUrl(String url) {
+    this.url = url;
+  }
+
+  public String getVerifier() {
+    return verifier;
+  }
+
+  public void setVerifier(String reporter) {
+    this.verifier = reporter;
+  }
+
+  public String getComment() {
+    return comment;
+  }
+
+  public void setComment(String comment) {
+    this.comment = comment;
+  }
+
+  @Override
+  public String toString() {
+    return new StringBuilder().append('[').append(key).append(": ")
+        .append(value).append(']').toString();
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    if (o instanceof PatchSetVerification) {
+      PatchSetVerification p = (PatchSetVerification) o;
+      return Objects.equals(key, p.key)
+          && Objects.equals(value, p.value)
+          && Objects.equals(granted, p.granted);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(key, value, granted);
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/ci/server/PatchSetVerificationAccess.java b/src/main/java/com/googlesource/gerrit/plugins/ci/server/PatchSetVerificationAccess.java
new file mode 100644
index 0000000..5d14103
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/ci/server/PatchSetVerificationAccess.java
@@ -0,0 +1,36 @@
+//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.googlesource.gerrit.plugins.ci.server;
+
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gwtorm.server.Access;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.PrimaryKey;
+import com.google.gwtorm.server.Query;
+import com.google.gwtorm.server.ResultSet;
+
+public interface PatchSetVerificationAccess extends
+ Access<PatchSetVerification, PatchSetVerification.Key> {
+  @Override
+  @PrimaryKey("key")
+  PatchSetVerification get(PatchSetVerification.Key key) throws OrmException;
+
+  @Query("WHERE key.patchSetId.changeId = ?")
+  ResultSet<PatchSetVerification> byChange(Change.Id id) throws OrmException;
+
+  @Query("WHERE key.patchSetId = ?")
+  ResultSet<PatchSetVerification> byPatchSet(PatchSet.Id id) throws OrmException;
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/ci/server/PostVerification.java b/src/main/java/com/googlesource/gerrit/plugins/ci/server/PostVerification.java
new file mode 100644
index 0000000..e7eaee0
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/ci/server/PostVerification.java
@@ -0,0 +1,143 @@
+// 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.googlesource.gerrit.plugins.ci.server;
+
+import com.google.common.base.Preconditions;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.LabelId;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+import com.googlesource.gerrit.plugins.ci.common.VerificationInfo;
+import com.googlesource.gerrit.plugins.ci.common.VerifyInput;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.List;
+import java.util.Map;
+
+@Singleton
+public class PostVerification
+    implements RestModifyView<RevisionResource, VerifyInput> {
+  private static final Logger log =
+      LoggerFactory.getLogger(PostVerification.class);
+  private final SchemaFactory<CiDb> schemaFactory;
+
+  @Inject
+  PostVerification(SchemaFactory<CiDb> schemaFactory) {
+    this.schemaFactory = schemaFactory;
+  }
+
+  @Override
+  public Response<?> apply(RevisionResource revision, VerifyInput input)
+      throws AuthException, BadRequestException, UnprocessableEntityException,
+      OrmException, IOException {
+    if (input.verifications == null) {
+      throw new BadRequestException("Missing verifications field");
+    }
+
+    try (CiDb db = schemaFactory.open()) {
+      // Only needed for Google Megastore (that we don't use yet)
+      db.patchSetVerifications().beginTransaction(null);
+      boolean dirty = false;
+      try {
+        dirty |= updateLabels(revision, db, input.verifications);
+        if (dirty) {
+          db.commit();
+        }
+      } finally {
+        db.rollback();
+      }
+    }
+    return Response.none();
+  }
+
+  private boolean updateLabels(RevisionResource resource, CiDb db,
+      Map<String, VerificationInfo> jobs)
+      throws OrmException, BadRequestException {
+    Preconditions.checkNotNull(jobs);
+
+    List<PatchSetVerification> ups = Lists.newArrayList();
+    Map<String, PatchSetVerification> current = scanLabels(resource, db);
+
+    Timestamp ts = TimeUtil.nowTs();
+    for (Map.Entry<String, VerificationInfo> ent : jobs.entrySet()) {
+      String name = ent.getKey();
+      PatchSetVerification c = current.remove(name);
+      Short value = ent.getValue().value;
+      if (value == null) {
+        throw new BadRequestException("Missing value field");
+      }
+      if (c != null) {
+        c.setGranted(ts);
+        c.setValue(value);
+        String url = ent.getValue().url;
+        if (url != null) {
+          c.setUrl(url);
+        }
+        String verifier = ent.getValue().verifier;
+        if (verifier != null) {
+          c.setVerifier(verifier);
+        }
+        String comment = ent.getValue().comment;
+        if (comment != null) {
+          c.setComment(comment);
+        }
+        log.info("Updating job " + c.getLabel() + " for change "
+            + c.getPatchSetId());
+        ups.add(c);
+      } else {
+        c = new PatchSetVerification(new PatchSetVerification.Key(
+                resource.getPatchSet().getId(),
+                new LabelId(name)),
+            value, TimeUtil.nowTs());
+        c.setGranted(ts);
+        c.setUrl(ent.getValue().url);
+        c.setVerifier(ent.getValue().verifier);
+        c.setComment(ent.getValue().comment);
+        log.info("Adding job " + c.getLabel() + " for change "
+            + c.getPatchSetId());
+        ups.add(c);
+      }
+    }
+
+    db.patchSetVerifications().upsert(ups);
+    return !ups.isEmpty();
+  }
+
+  private Map<String, PatchSetVerification> scanLabels(
+      RevisionResource resource, CiDb db)
+      throws OrmException {
+    Map<String, PatchSetVerification> current = Maps.newHashMap();
+    for (PatchSetVerification v : db.patchSetVerifications()
+        .byPatchSet(resource.getPatchSet().getId())) {
+      current.put(v.getLabelId().get(), v);
+    }
+    return current;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/ci/server/schema/CiBaseDataSourceType.java b/src/main/java/com/googlesource/gerrit/plugins/ci/server/schema/CiBaseDataSourceType.java
new file mode 100644
index 0000000..c839a1b
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/ci/server/schema/CiBaseDataSourceType.java
@@ -0,0 +1,58 @@
+// 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.googlesource.gerrit.plugins.ci.server.schema;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+import com.googlesource.gerrit.plugins.ci.server.CiDb;
+
+public abstract class CiBaseDataSourceType implements CiDataSourceType {
+
+  private final String driver;
+
+  protected CiBaseDataSourceType(String driver) {
+    this.driver = driver;
+  }
+
+  @Override
+  public final String getDriver() {
+    return driver;
+  }
+
+  @Override
+  public boolean usePool() {
+    return true;
+  }
+
+  @Override
+  public ScriptRunner getIndexScript() throws IOException {
+    return getScriptRunner("index_generic.sql");
+  }
+
+  protected static final ScriptRunner getScriptRunner(String path) throws IOException {
+    if (path == null) {
+      return ScriptRunner.NOOP;
+    }
+    ScriptRunner runner;
+    try (InputStream in = CiDb.class.getResourceAsStream(path)) {
+      if (in == null) {
+        throw new IllegalStateException("SQL script " + path + " not found");
+      }
+      runner = new ScriptRunner(path, in);
+    }
+    return runner;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/ci/server/schema/CiDataSourceModule.java b/src/main/java/com/googlesource/gerrit/plugins/ci/server/schema/CiDataSourceModule.java
new file mode 100644
index 0000000..58933fe
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/ci/server/schema/CiDataSourceModule.java
@@ -0,0 +1,30 @@
+// 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.googlesource.gerrit.plugins.ci.server.schema;
+
+import com.google.inject.AbstractModule;
+import com.google.inject.name.Names;
+
+public class CiDataSourceModule extends AbstractModule {
+
+  @Override
+  protected void configure() {
+    bind(CiDataSourceType.class).annotatedWith(Names.named("h2")).to(H2.class);
+    bind(CiDataSourceType.class).annotatedWith(Names.named("derby")).to(Derby.class);
+    bind(CiDataSourceType.class).annotatedWith(Names.named("mysql")).to(MySql.class);
+    bind(CiDataSourceType.class).annotatedWith(Names.named("oracle")).to(Oracle.class);
+    bind(CiDataSourceType.class).annotatedWith(Names.named("postgresql")).to(PostgreSQL.class);
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/ci/server/schema/CiDataSourceProvider.java b/src/main/java/com/googlesource/gerrit/plugins/ci/server/schema/CiDataSourceProvider.java
new file mode 100644
index 0000000..1a24291
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/ci/server/schema/CiDataSourceProvider.java
@@ -0,0 +1,194 @@
+// 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.googlesource.gerrit.plugins.ci.server.schema;
+
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import java.lang.reflect.Constructor;
+import java.lang.reflect.InvocationTargetException;
+import java.sql.SQLException;
+import java.util.Properties;
+
+import javax.sql.DataSource;
+
+import org.apache.commons.dbcp.BasicDataSource;
+import org.eclipse.jgit.lib.Config;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.annotations.PluginName;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.extensions.persistence.DataSourceInterceptor;
+import com.google.gerrit.metrics.CallbackMetric1;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.server.config.ConfigSection;
+import com.google.gerrit.server.config.ConfigUtil;
+import com.google.gerrit.server.config.PluginConfigFactory;
+import com.google.gwtorm.jdbc.SimpleDataSource;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.ProvisionException;
+import com.google.inject.Singleton;
+
+/** Provides access to the DataSource. */
+@Singleton
+public class CiDataSourceProvider implements Provider<DataSource>,
+    LifecycleListener {
+  public static final int DEFAULT_POOL_LIMIT = 8;
+
+  private final Config cfg;
+  private final MetricMaker metrics;
+  private final Context ctx;
+  private final CiDataSourceType dst;
+  private DataSource ds;
+
+  @Inject
+  protected CiDataSourceProvider(PluginConfigFactory cfgFactory,
+      @PluginName String pluginName,
+      MetricMaker metrics,
+      Context ctx,
+      CiDataSourceType dst) {
+    this.cfg = cfgFactory.getGlobalPluginConfig(pluginName);
+    this.metrics = metrics;
+    this.ctx = ctx;
+    this.dst = dst;
+  }
+
+  @Override
+  public synchronized DataSource get() {
+    if (ds == null) {
+      ds = open(cfg, ctx, dst);
+    }
+    return ds;
+  }
+
+  @Override
+  public void start() {
+  }
+
+  @Override
+  public synchronized void stop() {
+    if (ds instanceof BasicDataSource) {
+      try {
+        ((BasicDataSource) ds).close();
+      } catch (SQLException e) {
+        // Ignore the close failure.
+      }
+    }
+  }
+
+  public static enum Context {
+    SINGLE_USER, MULTI_USER
+  }
+
+  private DataSource open(Config cfg, Context context, CiDataSourceType dst) {
+    ConfigSection dbs = new ConfigSection(cfg, "database");
+    String driver = dbs.optional("driver");
+    if (Strings.isNullOrEmpty(driver)) {
+      driver = dst.getDriver();
+    }
+
+    String url = dbs.optional("url");
+    if (Strings.isNullOrEmpty(url)) {
+      url = dst.getUrl();
+    }
+
+    String username = dbs.optional("username");
+    String password = dbs.optional("password");
+    String interceptor = dbs.optional("dataSourceInterceptorClass");
+
+    boolean usePool;
+    if (context == Context.SINGLE_USER) {
+      usePool = false;
+    } else {
+      usePool = cfg.getBoolean("database", "connectionpool", dst.usePool());
+    }
+
+    if (usePool) {
+      final BasicDataSource ds = new BasicDataSource();
+      ds.setDriverClassName(driver);
+      ds.setUrl(url);
+      if (username != null && !username.isEmpty()) {
+        ds.setUsername(username);
+      }
+      if (password != null && !password.isEmpty()) {
+        ds.setPassword(password);
+      }
+      ds.setMaxActive(cfg.getInt("database", "poollimit", DEFAULT_POOL_LIMIT));
+      ds.setMinIdle(cfg.getInt("database", "poolminidle", 4));
+      ds.setMaxIdle(cfg.getInt("database", "poolmaxidle", 4));
+      ds.setMaxWait(ConfigUtil.getTimeUnit(cfg, "database", null,
+          "poolmaxwait", MILLISECONDS.convert(30, SECONDS), MILLISECONDS));
+      ds.setInitialSize(ds.getMinIdle());
+      exportPoolMetrics(ds);
+      return intercept(interceptor, ds);
+
+    } else {
+      // Don't use the connection pool.
+      //
+      try {
+        Properties p = new Properties();
+        p.setProperty("driver", driver);
+        p.setProperty("url", url);
+        if (username != null) {
+          p.setProperty("user", username);
+        }
+        if (password != null) {
+          p.setProperty("password", password);
+        }
+        return intercept(interceptor, new SimpleDataSource(p));
+      } catch (SQLException se) {
+        throw new ProvisionException("Database unavailable", se);
+      }
+    }
+  }
+
+  private void exportPoolMetrics(final BasicDataSource pool) {
+    final CallbackMetric1<Boolean, Integer> cnt = metrics.newCallbackMetric(
+        "sql/connection_pool/connections",
+        Integer.class,
+        new Description("SQL database connections")
+          .setGauge()
+          .setUnit("connections"),
+        Field.ofBoolean("active"));
+    metrics.newTrigger(cnt, new Runnable() {
+      @Override
+      public void run() {
+        synchronized (pool) {
+          cnt.set(true, pool.getNumActive());
+          cnt.set(false, pool.getNumIdle());
+        }
+      }
+    });
+  }
+
+  private DataSource intercept(String interceptor, DataSource ds) {
+    if (interceptor == null) {
+      return ds;
+    }
+    try {
+      Constructor<?> c = Class.forName(interceptor).getConstructor();
+      DataSourceInterceptor datasourceInterceptor =
+          (DataSourceInterceptor) c.newInstance();
+      return datasourceInterceptor.intercept("CiDb", ds);
+    } catch (ClassNotFoundException | SecurityException | NoSuchMethodException
+        | IllegalArgumentException | InstantiationException
+        | IllegalAccessException | InvocationTargetException e) {
+      throw new ProvisionException("Cannot intercept datasource", e);
+    }
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/ci/server/schema/CiDataSourceType.java b/src/main/java/com/googlesource/gerrit/plugins/ci/server/schema/CiDataSourceType.java
new file mode 100644
index 0000000..59f565b
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/ci/server/schema/CiDataSourceType.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.googlesource.gerrit.plugins.ci.server.schema;
+
+import java.io.IOException;
+
+/** Abstraction of a supported database platform */
+public interface CiDataSourceType {
+
+  public String getDriver();
+
+  public String getUrl();
+
+  public boolean usePool();
+
+  /**
+   * Return a ScriptRunner that runs the index script. Must not return
+   * {@code null}, but may return a ScriptRunner that does nothing.
+   *
+   * @throws IOException
+   */
+  public ScriptRunner getIndexScript() throws IOException;
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/ci/server/schema/CiDataSourceTypeGuesser.java b/src/main/java/com/googlesource/gerrit/plugins/ci/server/schema/CiDataSourceTypeGuesser.java
new file mode 100644
index 0000000..8417304
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/ci/server/schema/CiDataSourceTypeGuesser.java
@@ -0,0 +1,55 @@
+// 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.googlesource.gerrit.plugins.ci.server.schema;
+
+import java.nio.file.Path;
+
+import org.eclipse.jgit.lib.Config;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.annotations.PluginName;
+import com.google.gerrit.server.config.PluginConfigFactory;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.inject.Inject;
+import com.google.inject.ProvisionException;
+
+public class CiDataSourceTypeGuesser {
+
+  private final Config cfg;
+  private final String configFile;
+
+  @Inject
+  CiDataSourceTypeGuesser(SitePaths site,
+      PluginConfigFactory pluginConfig,
+      @PluginName String pluginName) {
+    this.cfg = pluginConfig.getGlobalPluginConfig(pluginName);
+    configFile = String.format("%s.config", pluginName);
+    Path config = site.resolve("etc").resolve(configFile);
+    if (!config.toFile().exists()) {
+      throw new ProvisionException(
+          String.format("Config file %s for plugin %s doesn't exist",
+              configFile, pluginName));
+    }
+  }
+
+  public String guessDataSourceType() {
+    String dbType = cfg.getString("database", null, "type");
+    if (Strings.isNullOrEmpty(dbType)) {
+      throw new ProvisionException(
+          String.format("'database.type' must be defined in: %s", configFile));
+    }
+    return dbType.toLowerCase();
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/ci/server/schema/CiDatabaseModule.java b/src/main/java/com/googlesource/gerrit/plugins/ci/server/schema/CiDatabaseModule.java
new file mode 100644
index 0000000..49c7b66
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/ci/server/schema/CiDatabaseModule.java
@@ -0,0 +1,34 @@
+// 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.googlesource.gerrit.plugins.ci.server.schema;
+
+import static com.google.inject.Scopes.SINGLETON;
+
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gwtorm.jdbc.Database;
+import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.TypeLiteral;
+import com.googlesource.gerrit.plugins.ci.server.CiDb;
+
+/** Loads the database with standard dependencies. */
+public class CiDatabaseModule extends FactoryModule {
+  @Override
+  protected void configure() {
+    bind(new TypeLiteral<SchemaFactory<CiDb>>() {}).to(
+        new TypeLiteral<Database<CiDb>>() {}).in(SINGLETON);
+    bind(new TypeLiteral<Database<CiDb>>() {}).toProvider(
+        CiDbDatabaseProvider.class).in(SINGLETON);
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/ci/server/schema/CiDbDatabaseProvider.java b/src/main/java/com/googlesource/gerrit/plugins/ci/server/schema/CiDbDatabaseProvider.java
new file mode 100644
index 0000000..67cde9e
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/ci/server/schema/CiDbDatabaseProvider.java
@@ -0,0 +1,44 @@
+// 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.googlesource.gerrit.plugins.ci.server.schema;
+
+import javax.sql.DataSource;
+
+import com.google.gwtorm.jdbc.Database;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.ProvisionException;
+import com.google.inject.name.Named;
+import com.googlesource.gerrit.plugins.ci.server.CiDb;
+
+/** Provides the {@code Database<CiDb>} database handle. */
+final class CiDbDatabaseProvider implements Provider<Database<CiDb>> {
+  private final DataSource datasource;
+
+  @Inject
+  CiDbDatabaseProvider(@Named("CiDb") DataSource ds) {
+    datasource = ds;
+  }
+
+  @Override
+  public Database<CiDb> get() {
+    try {
+      return new Database<>(datasource, CiDb.class);
+    } catch (OrmException e) {
+      throw new ProvisionException("Cannot create CiDb", e);
+    }
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/ci/server/schema/Derby.java b/src/main/java/com/googlesource/gerrit/plugins/ci/server/schema/Derby.java
new file mode 100644
index 0000000..f89662e
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/ci/server/schema/Derby.java
@@ -0,0 +1,46 @@
+// 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.googlesource.gerrit.plugins.ci.server.schema;
+
+import com.google.gerrit.extensions.annotations.PluginName;
+import com.google.gerrit.server.config.PluginConfigFactory;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.inject.Inject;
+
+import org.eclipse.jgit.lib.Config;
+
+class Derby extends CiBaseDataSourceType {
+
+  private final Config cfg;
+  private final SitePaths site;
+
+  @Inject
+  Derby(SitePaths site,
+      PluginConfigFactory pluginConfig,
+      @PluginName String pluginName) {
+    super("org.apache.derby.jdbc.EmbeddedDriver");
+    this.cfg = pluginConfig.getGlobalPluginConfig(pluginName);
+    this.site = site;
+  }
+
+  @Override
+  public String getUrl() {
+    String database = cfg.getString("database", null, "database");
+    if (database == null || database.isEmpty()) {
+      database = "db/CiDB";
+    }
+    return "jdbc:derby:" + site.resolve(database).toString() + ";create=true";
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/ci/server/schema/H2.java b/src/main/java/com/googlesource/gerrit/plugins/ci/server/schema/H2.java
new file mode 100644
index 0000000..7d29865
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/ci/server/schema/H2.java
@@ -0,0 +1,46 @@
+// 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.googlesource.gerrit.plugins.ci.server.schema;
+
+import org.eclipse.jgit.lib.Config;
+
+import com.google.gerrit.extensions.annotations.PluginName;
+import com.google.gerrit.server.config.PluginConfigFactory;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.inject.Inject;
+
+class H2 extends CiBaseDataSourceType {
+
+  private final Config cfg;
+  private final SitePaths site;
+
+  @Inject
+  H2(SitePaths site,
+      PluginConfigFactory pluginConfig,
+      @PluginName String pluginName) {
+    super("org.h2.Driver");
+    this.cfg = pluginConfig.getGlobalPluginConfig(pluginName);
+    this.site = site;
+  }
+
+  @Override
+  public String getUrl() {
+    String database = cfg.getString("database", null, "database");
+    if (database == null || database.isEmpty()) {
+      database = "db/CiDB";
+    }
+    return "jdbc:h2:" + site.resolve(database).toUri().toString();
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/ci/server/schema/MySql.java b/src/main/java/com/googlesource/gerrit/plugins/ci/server/schema/MySql.java
new file mode 100644
index 0000000..b5ad309
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/ci/server/schema/MySql.java
@@ -0,0 +1,56 @@
+// 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.googlesource.gerrit.plugins.ci.server.schema;
+
+import static com.google.gerrit.server.schema.JdbcUtil.hostname;
+import static com.google.gerrit.server.schema.JdbcUtil.port;
+
+import com.google.gerrit.server.config.ConfigSection;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.inject.Inject;
+
+import org.eclipse.jgit.lib.Config;
+
+class MySql extends CiBaseDataSourceType {
+
+  private Config cfg;
+
+  @Inject
+  public MySql(@GerritServerConfig final Config cfg) {
+    super("com.mysql.jdbc.Driver");
+    this.cfg = cfg;
+  }
+
+  @Override
+  public String getUrl() {
+    final StringBuilder b = new StringBuilder();
+    final ConfigSection dbs = new ConfigSection(cfg, "database");
+    b.append("jdbc:mysql://");
+    b.append(hostname(dbs.optional("hostname")));
+    b.append(port(dbs.optional("port")));
+    b.append("/");
+    b.append(dbs.required("database"));
+    return b.toString();
+  }
+
+  @Override
+  public boolean usePool() {
+    // MySQL has given us trouble with the connection pool,
+    // sometimes the backend disconnects and the pool winds
+    // up with a stale connection. Fortunately opening up
+    // a new MySQL connection is usually very fast.
+    return false;
+  }
+}
\ No newline at end of file
diff --git a/src/main/java/com/googlesource/gerrit/plugins/ci/server/schema/Oracle.java b/src/main/java/com/googlesource/gerrit/plugins/ci/server/schema/Oracle.java
new file mode 100644
index 0000000..ce766a4
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/ci/server/schema/Oracle.java
@@ -0,0 +1,46 @@
+// 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.googlesource.gerrit.plugins.ci.server.schema;
+
+import static com.google.gerrit.server.schema.JdbcUtil.hostname;
+import static com.google.gerrit.server.schema.JdbcUtil.port;
+
+import com.google.gerrit.server.config.ConfigSection;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.inject.Inject;
+
+import org.eclipse.jgit.lib.Config;
+
+public class Oracle extends CiBaseDataSourceType {
+  private Config cfg;
+
+  @Inject
+  public Oracle(@GerritServerConfig Config cfg) {
+    super("oracle.jdbc.driver.OracleDriver");
+    this.cfg = cfg;
+  }
+
+  @Override
+  public String getUrl() {
+    final StringBuilder b = new StringBuilder();
+    final ConfigSection dbc = new ConfigSection(cfg, "database");
+    b.append("jdbc:oracle:thin:@");
+    b.append(hostname(dbc.optional("hostname")));
+    b.append(port(dbc.optional("port")));
+    b.append(":");
+    b.append(dbc.required("instance"));
+    return b.toString();
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/ci/server/schema/PostgreSQL.java b/src/main/java/com/googlesource/gerrit/plugins/ci/server/schema/PostgreSQL.java
new file mode 100644
index 0000000..ed41857
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/ci/server/schema/PostgreSQL.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.googlesource.gerrit.plugins.ci.server.schema;
+
+import static com.google.gerrit.server.schema.JdbcUtil.hostname;
+import static com.google.gerrit.server.schema.JdbcUtil.port;
+
+import com.google.gerrit.server.config.ConfigSection;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.inject.Inject;
+
+import org.eclipse.jgit.lib.Config;
+
+import java.io.IOException;
+
+class PostgreSQL extends CiBaseDataSourceType {
+
+  private Config cfg;
+
+  @Inject
+  public PostgreSQL(@GerritServerConfig Config cfg) {
+    super("org.postgresql.Driver");
+    this.cfg = cfg;
+  }
+
+  @Override
+  public String getUrl() {
+    final StringBuilder b = new StringBuilder();
+    final ConfigSection dbc = new ConfigSection(cfg, "database");
+    b.append("jdbc:postgresql://");
+    b.append(hostname(dbc.optional("hostname")));
+    b.append(port(dbc.optional("port")));
+    b.append("/");
+    b.append(dbc.required("database"));
+    return b.toString();
+  }
+
+  @Override
+  public ScriptRunner getIndexScript() throws IOException {
+    return getScriptRunner("index_postgres.sql");
+  }
+}
\ No newline at end of file
diff --git a/src/main/java/com/googlesource/gerrit/plugins/ci/server/schema/SchemaVersion.java b/src/main/java/com/googlesource/gerrit/plugins/ci/server/schema/SchemaVersion.java
new file mode 100644
index 0000000..1517824
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/ci/server/schema/SchemaVersion.java
@@ -0,0 +1,205 @@
+// 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.googlesource.gerrit.plugins.ci.server.schema;
+
+import com.google.common.collect.Lists;
+import com.google.gerrit.reviewdb.client.CurrentSchemaVersion;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gwtorm.jdbc.JdbcExecutor;
+import com.google.gwtorm.jdbc.JdbcSchema;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.StatementExecutor;
+import com.google.inject.Provider;
+
+import java.sql.PreparedStatement;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.Collections;
+import java.util.List;
+
+/** A version of the database schema. */
+public abstract class SchemaVersion {
+  /** The current schema version. */
+  public static final Class<Schema_1> C = Schema_1.class;
+
+  public static int getBinaryVersion() {
+    return guessVersion(C);
+  }
+
+  private final Provider<? extends SchemaVersion> prior;
+  private final int versionNbr;
+
+  protected SchemaVersion(Provider<? extends SchemaVersion> prior) {
+    this.prior = prior;
+    this.versionNbr = guessVersion(getClass());
+  }
+
+  public static int guessVersion(Class<?> c) {
+    String n = c.getName();
+    n = n.substring(n.lastIndexOf('_') + 1);
+    while (n.startsWith("0")) {
+      n = n.substring(1);
+    }
+    return Integer.parseInt(n);
+  }
+
+  /** @return the {@link CurrentSchemaVersion#versionNbr} this step targets. */
+  public final int getVersionNbr() {
+    return versionNbr;
+  }
+
+  public final void check(UpdateUI ui, CurrentSchemaVersion curr, ReviewDb db)
+      throws OrmException, SQLException {
+    if (curr.versionNbr == versionNbr) {
+      // Nothing to do, we are at the correct schema.
+    } else if (curr.versionNbr > versionNbr) {
+      throw new OrmException("Cannot downgrade database schema from version "
+          + curr.versionNbr + " to " + versionNbr + ".");
+    } else {
+      upgradeFrom(ui, curr, db);
+    }
+  }
+
+  /** Runs check on the prior schema version, and then upgrades. */
+  private void upgradeFrom(UpdateUI ui, CurrentSchemaVersion curr, ReviewDb db)
+      throws OrmException, SQLException {
+    List<SchemaVersion> pending = pending(curr.versionNbr);
+    updateSchema(pending, ui, db);
+    migrateData(pending, ui, curr, db);
+
+    JdbcSchema s = (JdbcSchema) db;
+    final List<String> pruneList = Lists.newArrayList();
+    s.pruneSchema(new StatementExecutor() {
+      @Override
+      public void execute(String sql) {
+        pruneList.add(sql);
+      }
+
+      @Override
+      public void close() {
+        // Do nothing.
+      }
+    });
+
+    try (JdbcExecutor e = new JdbcExecutor(s)) {
+      if (!pruneList.isEmpty()) {
+        ui.pruneSchema(e, pruneList);
+      }
+    }
+  }
+
+  private List<SchemaVersion> pending(int curr) {
+    List<SchemaVersion> r = Lists.newArrayListWithCapacity(versionNbr - curr);
+    for (SchemaVersion v = this; curr < v.getVersionNbr(); v = v.prior.get()) {
+      r.add(v);
+    }
+    Collections.reverse(r);
+    return r;
+  }
+
+  private void updateSchema(List<SchemaVersion> pending, UpdateUI ui,
+      ReviewDb db) throws OrmException, SQLException {
+    for (SchemaVersion v : pending) {
+      ui.message(String.format("Upgrading schema to %d ...", v.getVersionNbr()));
+      v.preUpdateSchema(db);
+    }
+
+    JdbcSchema s = (JdbcSchema) db;
+    try (JdbcExecutor e = new JdbcExecutor(s)) {
+      s.updateSchema(e);
+    }
+  }
+
+  /**
+   * Invoked before updateSchema adds new columns/tables.
+   *
+   * @param db open database handle.
+   * @throws OrmException if a Gerrit-specific exception occurred.
+   * @throws SQLException if an underlying SQL exception occurred.
+   */
+  protected void preUpdateSchema(ReviewDb db) throws OrmException, SQLException {
+  }
+
+  private void migrateData(List<SchemaVersion> pending, UpdateUI ui,
+      CurrentSchemaVersion curr, ReviewDb db) throws OrmException, SQLException {
+    for (SchemaVersion v : pending) {
+      ui.message(String.format(
+          "Migrating data to schema %d ...",
+          v.getVersionNbr()));
+      v.migrateData(db, ui);
+      v.finish(curr, db);
+    }
+  }
+
+  /**
+   * Invoked between updateSchema (adds new columns/tables) and pruneSchema
+   * (removes deleted columns/tables).
+   *
+   * @param db open database handle.
+   * @param ui interface for interacting with the user.
+   * @throws OrmException if a Gerrit-specific exception occurred.
+   * @throws SQLException if an underlying SQL exception occurred.
+   */
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
+  }
+
+  /** Mark the current schema version. */
+  protected void finish(CurrentSchemaVersion curr, ReviewDb db)
+      throws OrmException {
+    curr.versionNbr = versionNbr;
+    db.schemaVersion().update(Collections.singleton(curr));
+  }
+
+  /** Rename an existing table. */
+  protected static void renameTable(ReviewDb db, String from, String to)
+      throws OrmException {
+    JdbcSchema s = (JdbcSchema) db;
+    try (JdbcExecutor e = new JdbcExecutor(s)) {
+      s.renameTable(e, from, to);
+    }
+  }
+
+  /** Rename an existing column. */
+  protected static void renameColumn(ReviewDb db, String table, String from, String to)
+      throws OrmException {
+    JdbcSchema s = (JdbcSchema) db;
+    try (JdbcExecutor e = new JdbcExecutor(s)) {
+      s.renameField(e, table, from, to);
+    }
+  }
+
+  /** Execute an SQL statement. */
+  protected static void execute(ReviewDb db, String sql) throws SQLException {
+    try (Statement s = newStatement(db)) {
+      s.execute(sql);
+    }
+  }
+
+  /** Open a new single statement. */
+  protected static Statement newStatement(ReviewDb db) throws SQLException {
+    return ((JdbcSchema) db).getConnection().createStatement();
+  }
+
+  /** Open a new prepared statement. */
+  protected static PreparedStatement prepareStatement(ReviewDb db, String sql)
+      throws SQLException {
+    return ((JdbcSchema) db).getConnection().prepareStatement(sql);
+  }
+
+  /** Open a new statement executor. */
+  protected static JdbcExecutor newExecutor(ReviewDb db) throws OrmException {
+    return new JdbcExecutor(((JdbcSchema) db).getConnection());
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/ci/server/schema/Schema_1.java b/src/main/java/com/googlesource/gerrit/plugins/ci/server/schema/Schema_1.java
new file mode 100644
index 0000000..d4620ac
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/ci/server/schema/Schema_1.java
@@ -0,0 +1,32 @@
+// 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.googlesource.gerrit.plugins.ci.server.schema;
+
+import com.google.gerrit.server.schema.SchemaVersion;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.ProvisionException;
+
+public class Schema_1 extends SchemaVersion {
+  @Inject
+  Schema_1() {
+    super(new Provider<SchemaVersion>() {
+      @Override
+      public SchemaVersion get() {
+        throw new ProvisionException("initial version");
+      }
+    });
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/ci/server/schema/ScriptRunner.java b/src/main/java/com/googlesource/gerrit/plugins/ci/server/schema/ScriptRunner.java
new file mode 100644
index 0000000..d16d087
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/ci/server/schema/ScriptRunner.java
@@ -0,0 +1,126 @@
+// 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.googlesource.gerrit.plugins.ci.server.schema;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.sql.Connection;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.ArrayList;
+import java.util.List;
+
+import com.google.common.base.CharMatcher;
+import com.google.gwtorm.jdbc.JdbcSchema;
+import com.google.gwtorm.schema.sql.SqlDialect;
+import com.google.gwtorm.server.OrmException;
+import com.googlesource.gerrit.plugins.ci.server.CiDb;
+
+/** Parses an SQL script from a resource file and later runs it. */
+class ScriptRunner {
+  private final String name;
+  private final List<String> commands;
+
+  static final ScriptRunner NOOP = new ScriptRunner(null, null) {
+    @Override
+    void run(CiDb db) {
+    }
+  };
+
+  ScriptRunner(String scriptName, InputStream script) {
+    this.name = scriptName;
+    try {
+      this.commands = script != null ? parse(script) : null;
+    } catch (IOException e) {
+      throw new IllegalStateException("Cannot parse " + name, e);
+    }
+  }
+
+  void run(CiDb db) throws OrmException {
+    try {
+      JdbcSchema schema = (JdbcSchema) db;
+      Connection c = schema.getConnection();
+      SqlDialect dialect = schema.getDialect();
+      try (Statement stmt = c.createStatement()) {
+        for (String sql : commands) {
+          try {
+            if (!dialect.isStatementDelimiterSupported()) {
+              sql = CharMatcher.is(';').trimTrailingFrom(sql);
+            }
+            stmt.execute(sql);
+          } catch (SQLException e) {
+            throw new OrmException("Error in " + name + ":\n" + sql, e);
+          }
+        }
+      }
+    } catch (SQLException e) {
+      throw new OrmException("Cannot run statements for " + name, e);
+    }
+  }
+
+  private List<String> parse(InputStream in) throws IOException {
+    try (BufferedReader br = new BufferedReader(new InputStreamReader(in, UTF_8))) {
+      String delimiter = ";";
+      List<String> commands = new ArrayList<>();
+      StringBuilder buffer = new StringBuilder();
+      String line;
+      while ((line = br.readLine()) != null) {
+        if (line.isEmpty()) {
+          continue;
+        }
+        if (line.startsWith("--")) {
+          continue;
+        }
+
+        if (buffer.length() == 0 && line.toLowerCase().startsWith("delimiter ")) {
+          delimiter = line.substring("delimiter ".length()).trim();
+          continue;
+        }
+
+        if (buffer.length() > 0) {
+          buffer.append('\n');
+        }
+        buffer.append(line);
+
+        if (isDone(delimiter, line, buffer)) {
+          String cmd = buffer.toString();
+          commands.add(cmd);
+          buffer = new StringBuilder();
+        }
+      }
+      if (buffer.length() > 0) {
+        commands.add(buffer.toString());
+      }
+      return commands;
+    }
+  }
+
+  private boolean isDone(String delimiter, String line, StringBuilder buffer) {
+    if (";".equals(delimiter)) {
+      return buffer.charAt(buffer.length() - 1) == ';';
+
+    } else if (line.equals(delimiter)) {
+      buffer.setLength(buffer.length() - delimiter.length());
+      return true;
+
+    } else {
+      return false;
+    }
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/ci/server/schema/UpdateUI.java b/src/main/java/com/googlesource/gerrit/plugins/ci/server/schema/UpdateUI.java
new file mode 100644
index 0000000..4a1e0aa
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/ci/server/schema/UpdateUI.java
@@ -0,0 +1,31 @@
+// 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.googlesource.gerrit.plugins.ci.server.schema;
+
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.StatementExecutor;
+
+import java.util.List;
+
+public interface UpdateUI {
+  void message(String msg);
+
+  boolean yesno(boolean def, String msg);
+
+  boolean isBatch();
+
+  void pruneSchema(StatementExecutor e, List<String> pruneList)
+      throws OrmException;
+}