Add initial plugin code

Based on pre-reviewed code in Gerrit core:
067697f26e3cafbd097f1ff36adc606cc928f1a7
https://gerrit-review.googlesource.com/c/gerrit/+/215003/1

BUILD files modified to build in the new directory layout (but note that
the Java package layout is the same).

Change-Id: Ie2f6df068dcc43497e55e60882d94d517ee9eaf5
diff --git a/BUILD b/BUILD
new file mode 100644
index 0000000..72b581d
--- /dev/null
+++ b/BUILD
@@ -0,0 +1,33 @@
+package_group(
+    name = "visibility",
+    packages = ["//plugins/checks/..."],
+)
+
+package(default_visibility = [":visibility"])
+
+load(
+    "//tools/bzl:plugin.bzl",
+    "gerrit_plugin",
+)
+
+gerrit_plugin(
+    name = "checks",
+    srcs = glob(["java/com/google/gerrit/plugins/checks/**/*.java"]),
+    manifest_entries = [
+        "Gerrit-PluginName: checks",
+        "Gerrit-Module: com.google.gerrit.plugins.checks.Module",
+        "Gerrit-HttpModule: com.google.gerrit.plugins.checks.api.HttpModule",
+    ],
+    deps = [":checks-deps-neverlink"],
+)
+
+java_library(
+    name = "checks-deps-neverlink",
+    neverlink = True,
+    visibility = ["//visibility:private"],
+    exports = [
+        "//java/com/google/gerrit/server/api",
+        "//lib/auto:auto-value",
+        "//lib/auto:auto-value-annotations",
+    ],
+)
diff --git a/build_defs/checks.bzl b/build_defs/checks.bzl
new file mode 100644
index 0000000..388bcbe
--- /dev/null
+++ b/build_defs/checks.bzl
@@ -0,0 +1,34 @@
+def checks_java_library(
+        name,
+        gerrit_deps = [],
+        **kwargs):
+    """Creates a java_library where core deps are not included in the jar.
+
+    Usually, Gerrit plugins are restricted to the libraries exported explicitly
+    in the plugin API. For in-tree plugins, adding an additional dep on some
+    other part of Gerrit (including from //lib) would cause that dep to get
+    compiled into the plugin jar. We don't want that, so this macro takes care
+    of excluding all specified gerrit_deps from the plugin jar.
+
+    Dependencies from outside Gerrit core, such as from the plugin itself, may
+    be supplied as regular deps.
+
+    Args:
+      name: name of the resulting java_library.
+      gerrit_deps: dependencies from within Gerrit core, which should not be
+          linked into the final plugin jar.
+      **kwargs: additional arguments for the resulting java_library.
+    """
+    deps_lib = "__%s_deps_neverlink" % name
+    native.java_library(
+        name = deps_lib,
+        neverlink = 1,
+        visibility = ["//visibility:private"],
+        exports = gerrit_deps,
+    )
+
+    native.java_library(
+        name = name,
+        deps = [":" + deps_lib],
+        **kwargs
+    )
diff --git a/java/com/google/gerrit/plugins/checks/AdministrateCheckersCapability.java b/java/com/google/gerrit/plugins/checks/AdministrateCheckersCapability.java
new file mode 100644
index 0000000..0bc40be
--- /dev/null
+++ b/java/com/google/gerrit/plugins/checks/AdministrateCheckersCapability.java
@@ -0,0 +1,30 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.checks;
+
+import static com.google.gerrit.plugins.checks.AdministrateCheckersCapability.NAME;
+
+import com.google.gerrit.extensions.annotations.Export;
+import com.google.gerrit.extensions.config.CapabilityDefinition;
+
+@Export(NAME)
+public class AdministrateCheckersCapability extends CapabilityDefinition {
+  public static final String NAME = "administrateCheckers";
+
+  @Override
+  public String getDescription() {
+    return "Administrate Checkers";
+  }
+}
diff --git a/java/com/google/gerrit/plugins/checks/AdministrateCheckersPermission.java b/java/com/google/gerrit/plugins/checks/AdministrateCheckersPermission.java
new file mode 100644
index 0000000..c46572d
--- /dev/null
+++ b/java/com/google/gerrit/plugins/checks/AdministrateCheckersPermission.java
@@ -0,0 +1,28 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.checks;
+
+import com.google.gerrit.extensions.annotations.PluginName;
+import com.google.gerrit.extensions.api.access.PluginPermission;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+@Singleton
+public class AdministrateCheckersPermission extends PluginPermission {
+  @Inject
+  AdministrateCheckersPermission(@PluginName String pluginName) {
+    super(pluginName, AdministrateCheckersCapability.NAME, false);
+  }
+}
diff --git a/java/com/google/gerrit/plugins/checks/Checker.java b/java/com/google/gerrit/plugins/checks/Checker.java
new file mode 100644
index 0000000..dee99f6
--- /dev/null
+++ b/java/com/google/gerrit/plugins/checks/Checker.java
@@ -0,0 +1,130 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.checks;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.plugins.checks.api.CheckerStatus;
+import com.google.gerrit.reviewdb.client.Project;
+import java.sql.Timestamp;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
+
+/** Definition of a checker. */
+@AutoValue
+public abstract class Checker {
+
+  /**
+   * Returns the UUID of the checker.
+   *
+   * <p>The UUID is a SHA-1 that is unique across all checkers.
+   *
+   * @return UUID
+   */
+  public abstract String getUuid();
+
+  /**
+   * Returns the display name of the checker.
+   *
+   * <p>Checker names are not unique, checkers with the same name may exist.
+   *
+   * @return display name of the checker
+   */
+  public abstract String getName();
+
+  /**
+   * Returns the description of the checker.
+   *
+   * <p>Checkers may not have a description, in this case {@link Optional#empty()} is returned.
+   *
+   * @return the description of the checker
+   */
+  public abstract Optional<String> getDescription();
+
+  /**
+   * Returns the URL of the checker.
+   *
+   * <p>Checkers may not have a URL, in this case {@link Optional#empty()} is returned.
+   *
+   * @return the URL of the checker
+   */
+  public abstract Optional<String> getUrl();
+
+  /**
+   * Returns the repository to which the checker applies.
+   *
+   * <p>The repository is the exact name of a repository (no prefix, no regexp).
+   *
+   * @return the repository to which the checker applies
+   */
+  public abstract Project.NameKey getRepository();
+
+  /**
+   * Returns the status of the checker.
+   *
+   * @return the status of the checker.
+   */
+  public abstract CheckerStatus getStatus();
+
+  /**
+   * Returns the creation timestamp of the checker.
+   *
+   * @return the creation timestamp
+   */
+  public abstract Timestamp getCreatedOn();
+
+  /**
+   * Returns the timestamp of when the checker was last updated.
+   *
+   * @return the last updated timestamp
+   */
+  public abstract Timestamp getUpdatedOn();
+
+  /**
+   * Returns the ref state of the checker.
+   *
+   * @return the ref state
+   */
+  public abstract ObjectId getRefState();
+
+  public abstract Builder toBuilder();
+
+  public static Builder builder(String uuid) {
+    return new AutoValue_Checker.Builder().setUuid(uuid);
+  }
+
+  /** A builder for an {@link Checker}. */
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder setUuid(String uuid);
+
+    public abstract Builder setName(String name);
+
+    public abstract Builder setDescription(String description);
+
+    public abstract Builder setUrl(String url);
+
+    public abstract Builder setRepository(Project.NameKey repository);
+
+    public abstract Builder setStatus(CheckerStatus status);
+
+    public abstract Builder setCreatedOn(Timestamp createdOn);
+
+    public abstract Builder setUpdatedOn(Timestamp updatedOn);
+
+    public abstract Builder setRefState(ObjectId refState);
+
+    public abstract Checker build();
+  }
+}
diff --git a/java/com/google/gerrit/plugins/checks/CheckerCommitValidator.java b/java/com/google/gerrit/plugins/checks/CheckerCommitValidator.java
new file mode 100644
index 0000000..b98751f
--- /dev/null
+++ b/java/com/google/gerrit/plugins/checks/CheckerCommitValidator.java
@@ -0,0 +1,55 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.checks;
+
+import com.google.gerrit.extensions.annotations.Listen;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.git.validators.CommitValidationException;
+import com.google.gerrit.server.git.validators.CommitValidationListener;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
+import com.google.gerrit.server.util.MagicBranch;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.Collections;
+import java.util.List;
+
+/** Rejects updates to checker branches. */
+@Listen
+@Singleton
+public class CheckerCommitValidator implements CommitValidationListener {
+  private final AllProjectsName allProjects;
+
+  @Inject
+  public CheckerCommitValidator(AllProjectsName allProjects) {
+    this.allProjects = allProjects;
+  }
+
+  @Override
+  public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+      throws CommitValidationException {
+    if (!allProjects.equals(receiveEvent.project.getNameKey())
+        || !CheckerRef.isRefsCheckers(receiveEvent.getRefName())) {
+      return Collections.emptyList();
+    }
+
+    if (MagicBranch.isMagicBranch(receiveEvent.command.getRefName())
+        || RefNames.isRefsChanges(receiveEvent.command.getRefName())) {
+      throw new CommitValidationException("creating change for checker ref not allowed");
+    }
+    throw new CommitValidationException("direct update of checker ref not allowed");
+  }
+}
diff --git a/java/com/google/gerrit/plugins/checks/CheckerCreation.java b/java/com/google/gerrit/plugins/checks/CheckerCreation.java
new file mode 100644
index 0000000..a07188c
--- /dev/null
+++ b/java/com/google/gerrit/plugins/checks/CheckerCreation.java
@@ -0,0 +1,49 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.checks;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.reviewdb.client.Project;
+
+@AutoValue
+public abstract class CheckerCreation {
+  /**
+   * Defines the UUID the checker should have.
+   *
+   * <p>Must be a SHA-1 that is unique across all checkers.
+   */
+  public abstract String getCheckerUuid();
+
+  /** Defines the name the checker should have. */
+  public abstract String getName();
+
+  /** Defines the repository for which the checker applies. */
+  public abstract Project.NameKey getRepository();
+
+  public static Builder builder() {
+    return new AutoValue_CheckerCreation.Builder();
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder setCheckerUuid(String checkerUuid);
+
+    public abstract Builder setName(String name);
+
+    public abstract Builder setRepository(Project.NameKey repository);
+
+    public abstract CheckerCreation build();
+  }
+}
diff --git a/java/com/google/gerrit/plugins/checks/CheckerJson.java b/java/com/google/gerrit/plugins/checks/CheckerJson.java
new file mode 100644
index 0000000..37683ef
--- /dev/null
+++ b/java/com/google/gerrit/plugins/checks/CheckerJson.java
@@ -0,0 +1,35 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.checks;
+
+import com.google.gerrit.plugins.checks.api.CheckerInfo;
+import com.google.inject.Singleton;
+
+/** Formats a {@link Checker} as JSON. */
+@Singleton
+public class CheckerJson {
+  public CheckerInfo format(Checker checker) {
+    CheckerInfo info = new CheckerInfo();
+    info.uuid = checker.getUuid();
+    info.name = checker.getName();
+    info.description = checker.getDescription().orElse(null);
+    info.url = checker.getUrl().orElse(null);
+    info.repository = checker.getRepository().get();
+    info.status = checker.getStatus();
+    info.createdOn = checker.getCreatedOn();
+    info.updatedOn = checker.getUpdatedOn();
+    return info;
+  }
+}
diff --git a/java/com/google/gerrit/plugins/checks/CheckerMergeValidator.java b/java/com/google/gerrit/plugins/checks/CheckerMergeValidator.java
new file mode 100644
index 0000000..d90d321
--- /dev/null
+++ b/java/com/google/gerrit/plugins/checks/CheckerMergeValidator.java
@@ -0,0 +1,53 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.checks;
+
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.git.CodeReviewCommit;
+import com.google.gerrit.server.git.validators.MergeValidationException;
+import com.google.gerrit.server.git.validators.MergeValidationListener;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.inject.Inject;
+import org.eclipse.jgit.lib.Repository;
+
+public class CheckerMergeValidator implements MergeValidationListener {
+
+  private final AllProjectsName allProjectsName;
+
+  @Inject
+  public CheckerMergeValidator(AllProjectsName allProjectsName) {
+    this.allProjectsName = allProjectsName;
+  }
+
+  @Override
+  public void onPreMerge(
+      Repository repo,
+      CodeReviewCommit commit,
+      ProjectState destProject,
+      Branch.NameKey destBranch,
+      PatchSet.Id patchSetId,
+      IdentifiedUser caller)
+      throws MergeValidationException {
+    if (!allProjectsName.equals(destProject.getNameKey())
+        || !CheckerRef.isRefsCheckers(destBranch.get())) {
+      return;
+    }
+
+    throw new MergeValidationException("submit to checker ref not allowed");
+  }
+}
diff --git a/java/com/google/gerrit/plugins/checks/CheckerName.java b/java/com/google/gerrit/plugins/checks/CheckerName.java
new file mode 100644
index 0000000..3a3f837
--- /dev/null
+++ b/java/com/google/gerrit/plugins/checks/CheckerName.java
@@ -0,0 +1,25 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.checks;
+
+import com.google.common.base.Strings;
+
+public class CheckerName {
+  public static String clean(String checkerName) {
+    return Strings.nullToEmpty(checkerName).trim();
+  }
+
+  private CheckerName() {}
+}
diff --git a/java/com/google/gerrit/plugins/checks/CheckerRef.java b/java/com/google/gerrit/plugins/checks/CheckerRef.java
new file mode 100644
index 0000000..11a6d1f
--- /dev/null
+++ b/java/com/google/gerrit/plugins/checks/CheckerRef.java
@@ -0,0 +1,37 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.checks;
+
+import com.google.gerrit.reviewdb.client.RefNames;
+
+public class CheckerRef {
+  /** Ref namespace for checkers. */
+  public static final String REFS_CHECKERS = "refs/checkers/";
+
+  /** Ref that stores the repository to checkers map. */
+  public static final String REFS_META_CHECKERS = "refs/meta/checkers/";
+
+  public static String refsCheckers(String checkerUuid) {
+    return REFS_CHECKERS + RefNames.shardUuid(checkerUuid);
+  }
+
+  /**
+   * Whether the ref is a checker branch that stores NoteDb data of a checker. Returns {@code true}
+   * for all refs that start with {@code refs/checkers/}.
+   */
+  public static boolean isRefsCheckers(String ref) {
+    return ref.startsWith(REFS_CHECKERS);
+  }
+}
diff --git a/java/com/google/gerrit/plugins/checks/CheckerRefOperationValidator.java b/java/com/google/gerrit/plugins/checks/CheckerRefOperationValidator.java
new file mode 100644
index 0000000..c7de4b3
--- /dev/null
+++ b/java/com/google/gerrit/plugins/checks/CheckerRefOperationValidator.java
@@ -0,0 +1,52 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.checks;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.events.RefReceivedEvent;
+import com.google.gerrit.server.git.validators.RefOperationValidationListener;
+import com.google.gerrit.server.git.validators.ValidationMessage;
+import com.google.gerrit.server.validators.ValidationException;
+import java.util.List;
+import javax.inject.Inject;
+import javax.inject.Singleton;
+import org.eclipse.jgit.transport.ReceiveCommand;
+
+@Singleton
+public class CheckerRefOperationValidator implements RefOperationValidationListener {
+
+  private final AllProjectsName allProjectsName;
+
+  @Inject
+  CheckerRefOperationValidator(AllProjectsName allProjects) {
+    this.allProjectsName = allProjects;
+  }
+
+  @Override
+  public List<ValidationMessage> onRefOperation(RefReceivedEvent refEvent)
+      throws ValidationException {
+    if (refEvent.project.getNameKey().equals(allProjectsName)) {
+      if (CheckerRef.isRefsCheckers(refEvent.command.getRefName())) {
+        if (refEvent.command.getType().equals(ReceiveCommand.Type.CREATE)) {
+          throw new ValidationException("Not allowed to create checker ref.");
+        } else if (refEvent.command.getType().equals(ReceiveCommand.Type.DELETE)) {
+          throw new ValidationException("Not allowed to delete checker ref.");
+        }
+      }
+    }
+    return ImmutableList.of();
+  }
+}
diff --git a/java/com/google/gerrit/plugins/checks/CheckerUpdate.java b/java/com/google/gerrit/plugins/checks/CheckerUpdate.java
new file mode 100644
index 0000000..a9a4595
--- /dev/null
+++ b/java/com/google/gerrit/plugins/checks/CheckerUpdate.java
@@ -0,0 +1,84 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.checks;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.plugins.checks.api.CheckerStatus;
+import com.google.gerrit.reviewdb.client.Project;
+import java.sql.Timestamp;
+import java.util.Optional;
+
+@AutoValue
+public abstract class CheckerUpdate {
+  /** Defines the new name of the checker. If not specified, the name remains unchanged. */
+  public abstract Optional<String> getName();
+
+  /**
+   * Defines the new description of the checker. If not specified, the description remains
+   * unchanged.
+   *
+   * <p><strong>Note: </strong>Passing the empty string unsets the description.
+   */
+  public abstract Optional<String> getDescription();
+
+  /**
+   * Defines the new URL of the checker. If not specified, the URL remains unchanged.
+   *
+   * <p><strong>Note: </strong>Passing the empty string unsets the URL.
+   */
+  public abstract Optional<String> getUrl();
+
+  /**
+   * Defines the new repository for which the checker applies. If not specified, the repository
+   * remains unchanged.
+   */
+  public abstract Optional<Project.NameKey> getRepository();
+
+  /** Defines the new status for the checker. If not specified, the status remains unchanged. */
+  public abstract Optional<CheckerStatus> getStatus();
+
+  /**
+   * Defines the {@code Timestamp} to be used for the NoteDb commits of the update. If not
+   * specified, the current {@code Timestamp} when creating the commit will be used.
+   *
+   * <p>If this {@code CheckerUpdate} is passed next to a {@link CheckerCreation} during a checker
+   * creation, this {@code Timestamp} is used for the NoteDb commits of the new checker. Hence, the
+   * {@link Checker#getCreatedOn()} field will match this {@code Timestamp}.
+   */
+  public abstract Optional<Timestamp> getUpdatedOn();
+
+  public abstract Builder toBuilder();
+
+  public static Builder builder() {
+    return new AutoValue_CheckerUpdate.Builder();
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+    public abstract Builder setName(String name);
+
+    public abstract Builder setDescription(String description);
+
+    public abstract Builder setUrl(String url);
+
+    public abstract Builder setRepository(Project.NameKey repository);
+
+    public abstract Builder setStatus(CheckerStatus status);
+
+    public abstract Builder setUpdatedOn(Timestamp timestamp);
+
+    public abstract CheckerUpdate build();
+  }
+}
diff --git a/java/com/google/gerrit/plugins/checks/CheckerUrl.java b/java/com/google/gerrit/plugins/checks/CheckerUrl.java
new file mode 100644
index 0000000..601c4eb
--- /dev/null
+++ b/java/com/google/gerrit/plugins/checks/CheckerUrl.java
@@ -0,0 +1,54 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.checks;
+
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import java.net.URI;
+import java.net.URISyntaxException;
+
+public class CheckerUrl {
+  /**
+   * Cleans a user-provided URL.
+   *
+   * @param urlString URL string. Must be either empty (after trimming), or a valid http/https URL.
+   * @return input string after trimming, guaranteed to be either the empty string or a valid
+   *     http/https URL.
+   * @throws BadRequestException if the input is neither empty (after trimming) nor a valid URL.
+   */
+  public static String clean(String urlString) throws BadRequestException {
+    String trimmed = requireNonNull(urlString).trim();
+    if (trimmed.isEmpty()) {
+      return trimmed;
+    }
+    URI uri;
+    try {
+      uri = new URI(trimmed);
+    } catch (URISyntaxException e) {
+      uri = null;
+    }
+    if (uri == null || Strings.isNullOrEmpty(uri.getScheme())) {
+      throw new BadRequestException("invalid URL: " + urlString);
+    }
+    if (!uri.getScheme().equals("http") && !uri.getScheme().equals("https")) {
+      throw new BadRequestException("only http/https URLs supported: " + urlString);
+    }
+    return trimmed;
+  }
+
+  private CheckerUrl() {}
+}
diff --git a/java/com/google/gerrit/plugins/checks/CheckerUuid.java b/java/com/google/gerrit/plugins/checks/CheckerUuid.java
new file mode 100644
index 0000000..7338838
--- /dev/null
+++ b/java/com/google/gerrit/plugins/checks/CheckerUuid.java
@@ -0,0 +1,93 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.checks;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.RefNames;
+import java.security.MessageDigest;
+import java.util.Optional;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+
+public class CheckerUuid {
+  /**
+   * Creates a new UUID for a checker.
+   *
+   * <p>The creation of the UUID is non-deterministic. This means invoking this method multiple
+   * times with the same parameters will result in a different UUID for each call.
+   *
+   * @param checkerName checker name.
+   * @return checker UUID.
+   */
+  public static String make(String checkerName) {
+    MessageDigest md = Constants.newMessageDigest();
+    md.update(Constants.encode("checker " + checkerName + "\n"));
+    md.update(Constants.encode(String.valueOf(Math.random())));
+    return ObjectId.fromRaw(md.digest()).name();
+  }
+
+  /**
+   * Checks whether the given checker UUID has a valid format.
+   *
+   * @param checkerUuid the checker UUID to check
+   * @return {@code true} if the given checker UUID has a valid format, otherwise {@code false}
+   */
+  public static boolean isUuid(@Nullable String checkerUuid) {
+    return checkerUuid != null && ObjectId.isId(checkerUuid);
+  }
+
+  /**
+   * Checks whether the given checker UUID has a valid format.
+   *
+   * @param checkerUuid the checker UUID to check
+   * @return the checker UUID
+   * @throws IllegalStateException if the given checker UUID has an invalid format
+   */
+  public static String checkUuid(String checkerUuid) {
+    checkState(isUuid(checkerUuid), "invalid checker UUID: %s", checkerUuid);
+    return checkerUuid;
+  }
+
+  /**
+   * Parses a checker UUID from a checker ref.
+   *
+   * @param ref the ref from which a checker UUID should be parsed
+   * @return the checker UUID, {@link Optional#empty()} if the given ref is null or not a valid
+   *     checker ref
+   */
+  public static Optional<String> fromRef(@Nullable Ref ref) {
+    return fromRef(ref != null ? ref.getName() : (String) null);
+  }
+
+  /**
+   * Parses a checker UUID from a checker ref name.
+   *
+   * @param refName the name of the ref from which a checker UUID should be parsed
+   * @return the checker UUID, {@link Optional#empty()} if the given ref name is null or not a valid
+   *     checker ref name
+   */
+  public static Optional<String> fromRef(@Nullable String refName) {
+    if (refName == null || !CheckerRef.isRefsCheckers(refName)) {
+      return Optional.empty();
+    }
+    return Optional.ofNullable(
+        RefNames.parseShardedUuidFromRefPart(refName.substring(CheckerRef.REFS_CHECKERS.length())));
+  }
+
+  private CheckerUuid() {}
+}
diff --git a/java/com/google/gerrit/plugins/checks/Checkers.java b/java/com/google/gerrit/plugins/checks/Checkers.java
new file mode 100644
index 0000000..aaa1e91
--- /dev/null
+++ b/java/com/google/gerrit/plugins/checks/Checkers.java
@@ -0,0 +1,69 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.checks;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.reviewdb.client.Project;
+import java.io.IOException;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+/**
+ * A database accessor for read calls related to checkers.
+ *
+ * <p>All calls which read checker related details from the database are gathered here. Other
+ * classes should always use this class instead of accessing the database directly.
+ *
+ * <p>This is an interface so that the implementation can be swapped if needed.
+ */
+public interface Checkers {
+  /**
+   * Returns the checker for the given UUID.
+   *
+   * <p>If no checker with the given UUID exists, {@link Optional#empty()} is returned.
+   *
+   * @param checkerUuid the checker UUID
+   * @return the checker, {@link Optional#empty()} if no checker with the given UUID exists
+   * @throws IOException if the checker couldn't be retrieved from the storage
+   * @throws ConfigInvalidException if the checker in the storage is invalid
+   */
+  Optional<Checker> getChecker(String checkerUuid) throws IOException, ConfigInvalidException;
+
+  /**
+   * Returns a list with all checkers.
+   *
+   * <p>Checkers with invalid configuration are silently ignored.
+   *
+   * @return all checkers, sorted by UUID
+   * @throws IOException if any checker couldn't be retrieved from the storage
+   */
+  ImmutableList<Checker> listCheckers() throws IOException;
+
+  /**
+   * Returns the checkers that apply to the given repository.
+   *
+   * <p>Checkers with invalid configuration are silently ignored.
+   *
+   * @param repositoryName the name of the repository for which the applying checkers should be
+   *     returned
+   * @return the checkers that apply that apply to the given repository
+   * @throws IOException if reading the checker list fails or if any checker couldn't be retrieved
+   *     from the storage
+   * @throws ConfigInvalidException if reading the checker list fails
+   */
+  ImmutableSet<Checker> checkersOf(Project.NameKey repositoryName)
+      throws IOException, ConfigInvalidException;
+}
diff --git a/java/com/google/gerrit/plugins/checks/CheckersUpdate.java b/java/com/google/gerrit/plugins/checks/CheckersUpdate.java
new file mode 100644
index 0000000..b4c57dc
--- /dev/null
+++ b/java/com/google/gerrit/plugins/checks/CheckersUpdate.java
@@ -0,0 +1,64 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.checks;
+
+import com.google.gwtorm.server.OrmDuplicateKeyException;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+/**
+ * A database accessor for write calls related to checkers.
+ *
+ * <p>All calls which write checker related details to the database are gathered here. Other classes
+ * should always use this interface instead of accessing the database directly.
+ *
+ * <p>This is an interface so that the implementation can be swapped if needed.
+ *
+ * <p>Callers should use the {@link com.google.gerrit.server.UserInitiated} annotation or the {@link
+ * com.google.gerrit.server.ServerInitiated} annotation on a provider of a {@code CheckersUpdate} to
+ * get access to a {@code CheckersUpdate} instance.
+ */
+public interface CheckersUpdate {
+  /**
+   * Creates the specified checker.
+   *
+   * @param checkerCreation an {@code CheckerCreation} which specifies all mandatory properties of
+   *     the checker
+   * @param checkerUpdate an {@code CheckerUpdate} which specifies optional properties of the
+   *     checker. If this {@code CheckerUpdate} updates a property which was already specified by
+   *     the {@code CheckerCreation}, the value of this {@code CheckerUpdate} wins.
+   * @throws OrmDuplicateKeyException if a checker with the chosen UUID already exists
+   * @throws IOException if an error occurs while reading/writing from/to storage
+   * @throws ConfigInvalidException if a checker with the same UUID already exists but can't be read
+   *     due to an invalid format
+   * @return the created {@code Checker}
+   */
+  Checker createChecker(CheckerCreation checkerCreation, CheckerUpdate checkerUpdate)
+      throws OrmDuplicateKeyException, IOException, ConfigInvalidException;
+
+  /**
+   * Updates the specified checker.
+   *
+   * @param checkerUuid the UUID of the checker to update
+   * @param checkerUpdate an {@code CheckerUpdate} which indicates the desired updates on the
+   *     checker
+   * @throws NoSuchCheckerException if the specified checker doesn't exist
+   * @throws IOException if an error occurs while reading/writing from/to storage
+   * @throws ConfigInvalidException if the existing checker config is invalid
+   * @return the updated {@code Checker}
+   */
+  Checker updateChecker(String checkerUuid, CheckerUpdate checkerUpdate)
+      throws NoSuchCheckerException, IOException, ConfigInvalidException;
+}
diff --git a/java/com/google/gerrit/plugins/checks/Module.java b/java/com/google/gerrit/plugins/checks/Module.java
new file mode 100644
index 0000000..9e5e828
--- /dev/null
+++ b/java/com/google/gerrit/plugins/checks/Module.java
@@ -0,0 +1,41 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.checks;
+
+import static com.google.inject.Scopes.SINGLETON;
+
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.plugins.checks.db.NoteDbCheckersModule;
+import com.google.gerrit.server.git.validators.CommitValidationListener;
+import com.google.gerrit.server.git.validators.MergeValidationListener;
+import com.google.gerrit.server.git.validators.RefOperationValidationListener;
+
+public class Module extends FactoryModule {
+  @Override
+  protected void configure() {
+    install(new NoteDbCheckersModule());
+
+    DynamicSet.bind(binder(), CommitValidationListener.class)
+        .to(CheckerCommitValidator.class)
+        .in(SINGLETON);
+    DynamicSet.bind(binder(), MergeValidationListener.class)
+        .to(CheckerMergeValidator.class)
+        .in(SINGLETON);
+    DynamicSet.bind(binder(), RefOperationValidationListener.class)
+        .to(CheckerRefOperationValidator.class)
+        .in(SINGLETON);
+  }
+}
diff --git a/java/com/google/gerrit/plugins/checks/NoSuchCheckerException.java b/java/com/google/gerrit/plugins/checks/NoSuchCheckerException.java
new file mode 100644
index 0000000..ea06942
--- /dev/null
+++ b/java/com/google/gerrit/plugins/checks/NoSuchCheckerException.java
@@ -0,0 +1,30 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.checks;
+
+/** Indicates the checker does not exist. */
+public class NoSuchCheckerException extends Exception {
+  private static final long serialVersionUID = 1L;
+
+  public static final String MESSAGE = "Checker Not Found: ";
+
+  public NoSuchCheckerException(String uuid) {
+    this(uuid, null);
+  }
+
+  public NoSuchCheckerException(String uuid, Throwable why) {
+    super(MESSAGE + uuid, why);
+  }
+}
diff --git a/java/com/google/gerrit/plugins/checks/acceptance/AbstractCheckersTest.java b/java/com/google/gerrit/plugins/checks/acceptance/AbstractCheckersTest.java
new file mode 100644
index 0000000..5ad688b
--- /dev/null
+++ b/java/com/google/gerrit/plugins/checks/acceptance/AbstractCheckersTest.java
@@ -0,0 +1,52 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.checks.acceptance;
+
+import com.google.gerrit.acceptance.LightweightPluginDaemonTest;
+import com.google.gerrit.acceptance.ProjectResetter;
+import com.google.gerrit.acceptance.SkipProjectClone;
+import com.google.gerrit.acceptance.TestPlugin;
+import com.google.gerrit.plugins.checks.CheckerRef;
+import com.google.gerrit.plugins.checks.acceptance.testsuite.CheckerOperations;
+import com.google.gerrit.plugins.checks.api.Checkers;
+import org.junit.Before;
+
+// TODO(dborowitz): Improve the plugin test framework so we can avoid subclassing:
+//  * Defer injection until after the plugin is loaded, so we can @Inject members defined in plugin
+//    modules, rather than hard-coding them non-scalably like we do here.
+//  * Don't require all test classes to hard-code the @TestPlugin annotation.
+@TestPlugin(
+    name = "checks",
+    sysModule = "com.google.gerrit.plugins.checks.acceptance.TestModule",
+    httpModule = "com.google.gerrit.plugins.checks.api.HttpModule")
+@SkipProjectClone
+public class AbstractCheckersTest extends LightweightPluginDaemonTest {
+  protected CheckerOperations checkerOperations;
+  protected Checkers checkersApi;
+
+  @Override
+  protected ProjectResetter.Config resetProjects() {
+    return super.resetProjects()
+        .reset(allProjects, CheckerRef.REFS_CHECKERS + "*", CheckerRef.REFS_META_CHECKERS);
+  }
+
+  @Before
+  public void setUpCheckersPlugin() throws Exception {
+    checkerOperations = plugin.getSysInjector().getInstance(CheckerOperations.class);
+    checkersApi = plugin.getHttpInjector().getInstance(Checkers.class);
+
+    allowGlobalCapabilities(group("Administrators").getGroupUUID(), "checks-administrateCheckers");
+  }
+}
diff --git a/java/com/google/gerrit/plugins/checks/acceptance/BUILD b/java/com/google/gerrit/plugins/checks/acceptance/BUILD
new file mode 100644
index 0000000..6a1d023
--- /dev/null
+++ b/java/com/google/gerrit/plugins/checks/acceptance/BUILD
@@ -0,0 +1,14 @@
+package(
+    default_testonly = True,
+    default_visibility = ["//plugins/checks:visibility"],
+)
+
+java_library(
+    name = "acceptance",
+    srcs = glob(["*.java"]),
+    deps = [
+        "//java/com/google/gerrit/acceptance:lib",
+        "//plugins/checks:checks__plugin",
+        "//plugins/checks/java/com/google/gerrit/plugins/checks/acceptance/testsuite",
+    ],
+)
diff --git a/java/com/google/gerrit/plugins/checks/acceptance/TestModule.java b/java/com/google/gerrit/plugins/checks/acceptance/TestModule.java
new file mode 100644
index 0000000..7d6686a
--- /dev/null
+++ b/java/com/google/gerrit/plugins/checks/acceptance/TestModule.java
@@ -0,0 +1,31 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.checks.acceptance;
+
+import com.google.gerrit.plugins.checks.Module;
+import com.google.gerrit.plugins.checks.acceptance.testsuite.CheckerOperations;
+import com.google.gerrit.plugins.checks.acceptance.testsuite.CheckerOperationsImpl;
+import com.google.inject.AbstractModule;
+
+public class TestModule extends AbstractModule {
+  @Override
+  public void configure() {
+    install(new Module());
+
+    // Only add bindings here that are specifically required for tests, in order to keep the Guice
+    // setup in tests as realistic as possible by delegating to the original module.
+    bind(CheckerOperations.class).to(CheckerOperationsImpl.class);
+  }
+}
diff --git a/java/com/google/gerrit/plugins/checks/acceptance/testsuite/BUILD b/java/com/google/gerrit/plugins/checks/acceptance/testsuite/BUILD
new file mode 100644
index 0000000..62a5299
--- /dev/null
+++ b/java/com/google/gerrit/plugins/checks/acceptance/testsuite/BUILD
@@ -0,0 +1,13 @@
+package(
+    default_testonly = True,
+    default_visibility = ["//plugins/checks:visibility"],
+)
+
+java_library(
+    name = "testsuite",
+    srcs = glob(["*.java"]),
+    deps = [
+        "//java/com/google/gerrit/acceptance:lib",
+        "//plugins/checks:checks__plugin",
+    ],
+)
diff --git a/java/com/google/gerrit/plugins/checks/acceptance/testsuite/CheckerOperations.java b/java/com/google/gerrit/plugins/checks/acceptance/testsuite/CheckerOperations.java
new file mode 100644
index 0000000..4777697
--- /dev/null
+++ b/java/com/google/gerrit/plugins/checks/acceptance/testsuite/CheckerOperations.java
@@ -0,0 +1,154 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.checks.acceptance.testsuite;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.plugins.checks.api.CheckerInfo;
+import com.google.gerrit.reviewdb.client.Project;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevCommit;
+
+/**
+ * An aggregation of operations on checkers for test purposes.
+ *
+ * <p>To execute the operations, no Gerrit permissions are necessary.
+ *
+ * <p><strong>Note:</strong> This interface is not implemented using the REST or extension API.
+ * Hence, it cannot be used for testing those APIs.
+ */
+public interface CheckerOperations {
+  /**
+   * Starts the fluent chain for querying or modifying a checker. Please see the methods of {@link
+   * PerCheckerOperations} for details on possible operations.
+   *
+   * @return an aggregation of operations on a specific checker
+   */
+  PerCheckerOperations checker(String checkerUuid);
+
+  /**
+   * Starts the fluent chain to create a checker. The returned builder can be used to specify the
+   * attributes of the new checker. To create the checker for real, {@link
+   * TestCheckerCreation.Builder#create()} must be called.
+   *
+   * <p>Example:
+   *
+   * <pre>
+   * String createdCheckerUuid = checkerOperations
+   *     .newChecker()
+   *     .name("my-checker")
+   *     .description("A simple checker.")
+   *     .create();
+   * </pre>
+   *
+   * <p><strong>Note:</strong> If another checker with the provided name already exists, the
+   * creation of the checker will succeed since checker names are not unique.
+   *
+   * @return a builder to create the new checker
+   */
+  TestCheckerCreation.Builder newChecker();
+
+  /**
+   * Returns the UUIDs of the checkers that apply to the given repository.
+   *
+   * @param repositoryName repository name
+   * @return set of UUIDs of the checkers that apply to the given repository
+   * @throws IOException if reading the checker list fails
+   */
+  ImmutableSet<String> checkersOf(Project.NameKey repositoryName) throws IOException;
+
+  /**
+   * Returns the SHA1s of the repositories that have applying checkers.
+   *
+   * <p>These are the keys used in the {@code NoteMap} of {@code refs/meta/checkers}.
+   *
+   * @return the SHA1s of the repositories that have applying checkers
+   * @throws IOException if reading the repository SHA1s fails
+   */
+  ImmutableSet<ObjectId> sha1sOfRepositoriesWithCheckers() throws IOException;
+
+  /** An aggregation of methods on a specific checker. */
+  interface PerCheckerOperations {
+
+    /**
+     * Checks whether the checker exists.
+     *
+     * @return {@code true} if the checker exists
+     */
+    boolean exists();
+
+    /**
+     * Retrieves the checker.
+     *
+     * <p><strong>Note:</strong> This call will fail with an exception if the requested checker
+     * doesn't exist. If you want to check for the existence of a checker, use {@link #exists()}
+     * instead.
+     *
+     * @return the corresponding {@code TestChecker}
+     */
+    TestChecker get();
+
+    /**
+     * Retrieves the tip commit of the checker ref.
+     *
+     * <p><strong>Note:</strong>This call will fail with an exception if the checker doesn't exist.
+     *
+     * @return the tip commit of the checker ref
+     * @throws IOException if reading the commit fails
+     */
+    RevCommit commit() throws IOException;
+
+    /**
+     * Retrieves the checker config as text.
+     *
+     * <p>This call reads the checker config from the checker ref and returns it as text.
+     *
+     * <p><strong>Note:</strong>This call will fail with an exception if the checker doesn't exist.
+     *
+     * @return the checker config as text
+     * @throws IOException if reading the checker config fails
+     * @throws ConfigInvalidException if the checker config is invalid
+     */
+    String configText() throws IOException, ConfigInvalidException;
+
+    /**
+     * Returns this checker as {@link CheckerInfo}.
+     *
+     * <p><strong>Note:</strong>This call will fail with an exception if the checker doesn't exist.
+     *
+     * @return this checker as {@link CheckerInfo}
+     */
+    CheckerInfo asInfo();
+
+    /**
+     * Starts the fluent chain to update a checker. The returned builder can be used to specify how
+     * the attributes of the checker should be modified. To update the checker for real, {@link
+     * TestCheckerUpdate.Builder#update()} must be called.
+     *
+     * <p>Example:
+     *
+     * <pre>
+     * checkerOperations.forUpdate().description("Another description for this checker").update();
+     * </pre>
+     *
+     * <p><strong>Note:</strong> The update will fail with an exception if the checker to update
+     * doesn't exist. If you want to check for the existence of a checker, use {@link #exists()}.
+     *
+     * @return a builder to update the checker
+     */
+    TestCheckerUpdate.Builder forUpdate();
+  }
+}
diff --git a/java/com/google/gerrit/plugins/checks/acceptance/testsuite/CheckerOperationsImpl.java b/java/com/google/gerrit/plugins/checks/acceptance/testsuite/CheckerOperationsImpl.java
new file mode 100644
index 0000000..89f23b5
--- /dev/null
+++ b/java/com/google/gerrit/plugins/checks/acceptance/testsuite/CheckerOperationsImpl.java
@@ -0,0 +1,256 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.checks.acceptance.testsuite;
+
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+
+import com.google.common.base.Splitter;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Streams;
+import com.google.gerrit.plugins.checks.Checker;
+import com.google.gerrit.plugins.checks.CheckerCreation;
+import com.google.gerrit.plugins.checks.CheckerJson;
+import com.google.gerrit.plugins.checks.CheckerRef;
+import com.google.gerrit.plugins.checks.CheckerUpdate;
+import com.google.gerrit.plugins.checks.CheckerUuid;
+import com.google.gerrit.plugins.checks.Checkers;
+import com.google.gerrit.plugins.checks.CheckersUpdate;
+import com.google.gerrit.plugins.checks.NoSuchCheckerException;
+import com.google.gerrit.plugins.checks.api.CheckerInfo;
+import com.google.gerrit.plugins.checks.db.CheckerConfig;
+import com.google.gerrit.plugins.checks.db.CheckersByRepositoryNotes;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.ServerInitiated;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gwtorm.server.OrmDuplicateKeyException;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.BlobBasedConfig;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
+
+/**
+ * The implementation of {@code CheckerOperations}.
+ *
+ * <p>There is only one implementation of {@code CheckerOperations}. Nevertheless, we keep the
+ * separation between interface and implementation to enhance clarity.
+ */
+public class CheckerOperationsImpl implements CheckerOperations {
+  private final Checkers checkers;
+  private final CheckersUpdate checkersUpdate;
+  private final GitRepositoryManager repoManager;
+  private final AllProjectsName allProjectsName;
+  private final CheckerJson checkerJson;
+
+  @Inject
+  public CheckerOperationsImpl(
+      Checkers checkers,
+      @ServerInitiated CheckersUpdate checkersUpdate,
+      GitRepositoryManager repoManager,
+      AllProjectsName allProjectsName,
+      CheckerJson checkerJson) {
+    this.checkers = checkers;
+    this.checkersUpdate = checkersUpdate;
+    this.repoManager = repoManager;
+    this.allProjectsName = allProjectsName;
+    this.checkerJson = checkerJson;
+  }
+
+  @Override
+  public PerCheckerOperations checker(String checkerUuid) {
+    return new PerCheckerOperationsImpl(checkerUuid);
+  }
+
+  @Override
+  public TestCheckerCreation.Builder newChecker() {
+    return TestCheckerCreation.builder(this::createNewChecker);
+  }
+
+  private String createNewChecker(TestCheckerCreation testCheckerCreation)
+      throws OrmDuplicateKeyException, ConfigInvalidException, IOException {
+    CheckerCreation checkerCreation = toCheckerCreation(testCheckerCreation);
+    CheckerUpdate checkerUpdate = toCheckerUpdate(testCheckerCreation);
+    Checker checker = checkersUpdate.createChecker(checkerCreation, checkerUpdate);
+    return checker.getUuid();
+  }
+
+  private CheckerCreation toCheckerCreation(TestCheckerCreation checkerCreation) {
+    String checkerUuid = CheckerUuid.make("test-checker");
+    String checkerName = checkerCreation.name().orElse("checker-with-uuid-" + checkerUuid);
+    Project.NameKey repository = checkerCreation.repository().orElse(allProjectsName);
+    return CheckerCreation.builder()
+        .setCheckerUuid(checkerUuid)
+        .setName(checkerName)
+        .setRepository(repository)
+        .build();
+  }
+
+  private static CheckerUpdate toCheckerUpdate(TestCheckerCreation checkerCreation) {
+    CheckerUpdate.Builder builder = CheckerUpdate.builder();
+    checkerCreation.name().ifPresent(builder::setName);
+    checkerCreation.description().ifPresent(builder::setDescription);
+    checkerCreation.url().ifPresent(builder::setUrl);
+    checkerCreation.repository().ifPresent(builder::setRepository);
+    return builder.build();
+  }
+
+  @Override
+  public ImmutableSet<String> checkersOf(Project.NameKey repositoryName) throws IOException {
+    try (Repository repo = repoManager.openRepository(allProjectsName);
+        RevWalk rw = new RevWalk(repo);
+        ObjectReader or = repo.newObjectReader()) {
+      Ref ref = repo.exactRef(CheckerRef.REFS_META_CHECKERS);
+      if (ref == null) {
+        return ImmutableSet.of();
+      }
+
+      RevCommit c = rw.parseCommit(ref.getObjectId());
+      try (TreeWalk tw =
+          TreeWalk.forPath(
+              or,
+              CheckersByRepositoryNotes.computeRepositorySha1(repositoryName).getName(),
+              c.getTree())) {
+        if (tw == null) {
+          return ImmutableSet.of();
+        }
+
+        return ImmutableSet.copyOf(
+            Splitter.on('\n')
+                .splitToList(new String(or.open(tw.getObjectId(0), OBJ_BLOB).getBytes(), UTF_8)));
+      }
+    }
+  }
+
+  @Override
+  public ImmutableSet<ObjectId> sha1sOfRepositoriesWithCheckers() throws IOException {
+    try (Repository repo = repoManager.openRepository(allProjectsName);
+        RevWalk rw = new RevWalk(repo)) {
+      Ref ref = repo.exactRef(CheckerRef.REFS_META_CHECKERS);
+      if (ref == null) {
+        return ImmutableSet.of();
+      }
+
+      return Streams.stream(NoteMap.read(rw.getObjectReader(), rw.parseCommit(ref.getObjectId())))
+          .map(ObjectId::copy)
+          .collect(toImmutableSet());
+    }
+  }
+
+  private class PerCheckerOperationsImpl implements PerCheckerOperations {
+    private final String checkerUuid;
+
+    PerCheckerOperationsImpl(String checkerUuid) {
+      this.checkerUuid = checkerUuid;
+    }
+
+    @Override
+    public boolean exists() {
+      return getChecker(checkerUuid).isPresent();
+    }
+
+    @Override
+    public TestChecker get() {
+      Optional<Checker> checker = getChecker(checkerUuid);
+      checkState(checker.isPresent(), "Tried to get non-existing test checker");
+      return toTestChecker(checker.get());
+    }
+
+    private Optional<Checker> getChecker(String checkerUuid) {
+      try {
+        return checkers.getChecker(checkerUuid);
+      } catch (IOException | ConfigInvalidException e) {
+        throw new IllegalStateException(e);
+      }
+    }
+
+    private TestChecker toTestChecker(Checker checker) {
+      return TestChecker.builder()
+          .uuid(checker.getUuid())
+          .name(checker.getName())
+          .description(checker.getDescription())
+          .url(checker.getUrl())
+          .repository(checker.getRepository())
+          .createdOn(checker.getCreatedOn())
+          .updatedOn(checker.getUpdatedOn())
+          .refState(checker.getRefState())
+          .build();
+    }
+
+    @Override
+    public RevCommit commit() throws IOException {
+      Optional<Checker> checker = getChecker(checkerUuid);
+      checkState(checker.isPresent(), "Tried to get commit for a non-existing test checker");
+
+      try (Repository repo = repoManager.openRepository(allProjectsName);
+          RevWalk rw = new RevWalk(repo)) {
+        return rw.parseCommit(checker.get().getRefState());
+      }
+    }
+
+    @Override
+    public String configText() throws IOException, ConfigInvalidException {
+      Optional<Checker> checker = getChecker(checkerUuid);
+      checkState(checker.isPresent(), "Tried to get config text for a non-existing test checker");
+
+      try (Repository repo = repoManager.openRepository(allProjectsName);
+          RevWalk rw = new RevWalk(repo);
+          ObjectReader or = repo.newObjectReader()) {
+        // Parse as Config to ensure it's a valid config file.
+        return new BlobBasedConfig(
+                null, repo, checker.get().getRefState(), CheckerConfig.CHECKER_CONFIG_FILE)
+            .toText();
+      }
+    }
+
+    @Override
+    public CheckerInfo asInfo() {
+      Optional<Checker> checker = getChecker(checkerUuid);
+      checkState(checker.isPresent(), "Tried to get a non-existing test checker as CheckerInfo");
+      return checkerJson.format(checker.get());
+    }
+
+    public TestCheckerUpdate.Builder forUpdate() {
+      return TestCheckerUpdate.builder(this::updateChecker);
+    }
+
+    private void updateChecker(TestCheckerUpdate testCheckerUpdate)
+        throws NoSuchCheckerException, ConfigInvalidException, IOException {
+      CheckerUpdate checkerUpdate = toCheckerUpdate(testCheckerUpdate);
+      checkersUpdate.updateChecker(checkerUuid, checkerUpdate);
+    }
+
+    private CheckerUpdate toCheckerUpdate(TestCheckerUpdate checkerUpdate) {
+      CheckerUpdate.Builder builder = CheckerUpdate.builder();
+      checkerUpdate.name().ifPresent(builder::setName);
+      checkerUpdate.description().ifPresent(builder::setDescription);
+      checkerUpdate.url().ifPresent(builder::setUrl);
+      checkerUpdate.repository().ifPresent(builder::setRepository);
+      checkerUpdate.status().ifPresent(builder::setStatus);
+      return builder.build();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/plugins/checks/acceptance/testsuite/TestChecker.java b/java/com/google/gerrit/plugins/checks/acceptance/testsuite/TestChecker.java
new file mode 100644
index 0000000..cdc0df4
--- /dev/null
+++ b/java/com/google/gerrit/plugins/checks/acceptance/testsuite/TestChecker.java
@@ -0,0 +1,70 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.checks.acceptance.testsuite;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.reviewdb.client.Project;
+import java.sql.Timestamp;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
+
+@AutoValue
+public abstract class TestChecker {
+  public abstract String uuid();
+
+  public abstract String name();
+
+  public abstract Optional<String> description();
+
+  public abstract Optional<String> url();
+
+  public abstract Project.NameKey repository();
+
+  public abstract Timestamp createdOn();
+
+  public abstract Timestamp updatedOn();
+
+  public abstract ObjectId refState();
+
+  static Builder builder() {
+    return new AutoValue_TestChecker.Builder();
+  }
+
+  @AutoValue.Builder
+  abstract static class Builder {
+
+    public abstract Builder uuid(String checkerUuid);
+
+    public abstract Builder name(String name);
+
+    public abstract Builder description(String description);
+
+    public abstract Builder description(Optional<String> description);
+
+    public abstract Builder url(String url);
+
+    public abstract Builder url(Optional<String> url);
+
+    public abstract Builder repository(Project.NameKey repository);
+
+    public abstract Builder createdOn(Timestamp createdOn);
+
+    public abstract Builder updatedOn(Timestamp updatedOn);
+
+    public abstract Builder refState(ObjectId refState);
+
+    abstract TestChecker build();
+  }
+}
diff --git a/java/com/google/gerrit/plugins/checks/acceptance/testsuite/TestCheckerCreation.java b/java/com/google/gerrit/plugins/checks/acceptance/testsuite/TestCheckerCreation.java
new file mode 100644
index 0000000..43636a4
--- /dev/null
+++ b/java/com/google/gerrit/plugins/checks/acceptance/testsuite/TestCheckerCreation.java
@@ -0,0 +1,72 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.checks.acceptance.testsuite;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.acceptance.testsuite.ThrowingFunction;
+import com.google.gerrit.reviewdb.client.Project;
+import java.util.Optional;
+
+@AutoValue
+public abstract class TestCheckerCreation {
+
+  public abstract Optional<String> name();
+
+  public abstract Optional<String> description();
+
+  public abstract Optional<String> url();
+
+  public abstract Optional<Project.NameKey> repository();
+
+  abstract ThrowingFunction<TestCheckerCreation, String> checkerCreator();
+
+  public static Builder builder(ThrowingFunction<TestCheckerCreation, String> checkerCreator) {
+    return new AutoValue_TestCheckerCreation.Builder().checkerCreator(checkerCreator);
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    public abstract Builder name(String name);
+
+    public abstract Builder description(String description);
+
+    public Builder clearDescription() {
+      return description("");
+    }
+
+    public abstract Builder url(String url);
+
+    public Builder clearUrl() {
+      return url("");
+    }
+
+    public abstract Builder repository(Project.NameKey repository);
+
+    abstract Builder checkerCreator(ThrowingFunction<TestCheckerCreation, String> checkerCreator);
+
+    abstract TestCheckerCreation autoBuild();
+
+    /**
+     * Executes the checker creation as specified.
+     *
+     * @return the UUID of the created checker
+     */
+    public String create() {
+      TestCheckerCreation checkerCreation = autoBuild();
+      return checkerCreation.checkerCreator().applyAndThrowSilently(checkerCreation);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/plugins/checks/acceptance/testsuite/TestCheckerUpdate.java b/java/com/google/gerrit/plugins/checks/acceptance/testsuite/TestCheckerUpdate.java
new file mode 100644
index 0000000..9fb77bb
--- /dev/null
+++ b/java/com/google/gerrit/plugins/checks/acceptance/testsuite/TestCheckerUpdate.java
@@ -0,0 +1,80 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.checks.acceptance.testsuite;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.acceptance.testsuite.ThrowingConsumer;
+import com.google.gerrit.plugins.checks.api.CheckerStatus;
+import com.google.gerrit.reviewdb.client.Project;
+import java.util.Optional;
+
+@AutoValue
+public abstract class TestCheckerUpdate {
+  public abstract Optional<String> name();
+
+  public abstract Optional<String> description();
+
+  public abstract Optional<String> url();
+
+  public abstract Optional<Project.NameKey> repository();
+
+  public abstract Optional<CheckerStatus> status();
+
+  abstract ThrowingConsumer<TestCheckerUpdate> checkerUpdater();
+
+  public static Builder builder(ThrowingConsumer<TestCheckerUpdate> checkerUpdater) {
+    return new AutoValue_TestCheckerUpdate.Builder().checkerUpdater(checkerUpdater);
+  }
+
+  @AutoValue.Builder
+  public abstract static class Builder {
+
+    public abstract Builder name(String name);
+
+    public abstract Builder description(String description);
+
+    public Builder clearDescription() {
+      return description("");
+    }
+
+    public abstract Builder url(String url);
+
+    public Builder clearUrl() {
+      return url("");
+    }
+
+    public abstract Builder repository(Project.NameKey repository);
+
+    abstract Builder status(CheckerStatus status);
+
+    public Builder enable() {
+      return status(CheckerStatus.ENABLED);
+    }
+
+    public Builder disable() {
+      return status(CheckerStatus.DISABLED);
+    }
+
+    abstract Builder checkerUpdater(ThrowingConsumer<TestCheckerUpdate> checkerUpdater);
+
+    abstract TestCheckerUpdate autoBuild();
+
+    /** Executes the checker update as specified. */
+    public void update() {
+      TestCheckerUpdate checkerUpdater = autoBuild();
+      checkerUpdater.checkerUpdater().acceptAndThrowSilently(checkerUpdater);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/plugins/checks/api/CheckerApi.java b/java/com/google/gerrit/plugins/checks/api/CheckerApi.java
new file mode 100644
index 0000000..156fdc4
--- /dev/null
+++ b/java/com/google/gerrit/plugins/checks/api/CheckerApi.java
@@ -0,0 +1,62 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.checks.api;
+
+import com.google.gerrit.extensions.restapi.NotImplementedException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+
+public interface CheckerApi {
+  /** @return checker info. */
+  CheckerInfo get() throws RestApiException;
+
+  /**
+   * Updates a checker.
+   *
+   * <p>This method supports partial updates of the checker property set. Only properties that are
+   * set in the given input are updated. Properties that are not set in the input (that have `null`
+   * as value) are not touched.
+   *
+   * <p>Unsetting properties:
+   *
+   * <ul>
+   *   <li>{@code name}: Cannot be unset. Attempting to set it to an empty string ("") or a string
+   *       that is empty after trim is rejected as bad request.
+   *   <li>{@code description}: Can be unset by setting an empty string ("") for it.
+   *   <li>{@code url}: Can be unset by setting an empty string ("") for it.
+   *   <li>{@code repository}: Cannot be unset. Attempting to set it to an empty string ("") or a
+   *       string that is empty after trim is rejected as bad request.
+   * </ul>
+   *
+   * @param input input with updated properties
+   * @return updated checker info
+   */
+  CheckerInfo update(CheckerInput input) throws RestApiException;
+
+  /**
+   * A default implementation which allows source compatibility when adding new methods to the
+   * interface.
+   */
+  class NotImplemented implements CheckerApi {
+    @Override
+    public CheckerInfo get() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public CheckerInfo update(CheckerInput input) throws RestApiException {
+      throw new NotImplementedException();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/plugins/checks/api/CheckerApiImpl.java b/java/com/google/gerrit/plugins/checks/api/CheckerApiImpl.java
new file mode 100644
index 0000000..db74c00
--- /dev/null
+++ b/java/com/google/gerrit/plugins/checks/api/CheckerApiImpl.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.checks.api;
+
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+class CheckerApiImpl implements CheckerApi {
+  interface Factory {
+    CheckerApiImpl create(CheckerResource rsrc);
+  }
+
+  private final GetChecker getChecker;
+  private final UpdateChecker updateChecker;
+  private final CheckerResource rsrc;
+
+  @Inject
+  CheckerApiImpl(
+      GetChecker getChecker, UpdateChecker updateChecker, @Assisted CheckerResource rsrc) {
+    this.getChecker = getChecker;
+    this.updateChecker = updateChecker;
+
+    this.rsrc = rsrc;
+  }
+
+  @Override
+  public CheckerInfo get() throws RestApiException {
+    try {
+      return getChecker.apply(rsrc);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve checker", e);
+    }
+  }
+
+  @Override
+  public CheckerInfo update(CheckerInput input) throws RestApiException {
+    try {
+      return updateChecker.apply(rsrc, input);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot update checker", e);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/plugins/checks/api/CheckerInfo.java b/java/com/google/gerrit/plugins/checks/api/CheckerInfo.java
new file mode 100644
index 0000000..7dfee5a
--- /dev/null
+++ b/java/com/google/gerrit/plugins/checks/api/CheckerInfo.java
@@ -0,0 +1,65 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.checks.api;
+
+import com.google.common.base.MoreObjects;
+import java.sql.Timestamp;
+import java.util.Objects;
+
+public class CheckerInfo {
+  public String uuid;
+  public String name;
+  public String description;
+  public String url;
+  public String repository;
+  public CheckerStatus status;
+  public Timestamp createdOn;
+  public Timestamp updatedOn;
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(uuid, name, description, url, repository, status, createdOn, updatedOn);
+  }
+
+  @Override
+  public boolean equals(Object obj) {
+    if (!(obj instanceof CheckerInfo)) {
+      return false;
+    }
+    CheckerInfo o = (CheckerInfo) obj;
+    return Objects.equals(uuid, o.uuid)
+        && Objects.equals(name, o.name)
+        && Objects.equals(description, o.description)
+        && Objects.equals(url, o.url)
+        && Objects.equals(repository, o.repository)
+        && Objects.equals(status, o.status)
+        && Objects.equals(createdOn, o.createdOn)
+        && Objects.equals(updatedOn, o.updatedOn);
+  }
+
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper(this)
+        .add("uuid", uuid)
+        .add("name", name)
+        .add("description", description)
+        .add("repository", repository)
+        .add("url", url)
+        .add("status", status)
+        .add("createdOn", createdOn)
+        .add("updatedOn", updatedOn)
+        .toString();
+  }
+}
diff --git a/java/com/google/gerrit/plugins/checks/api/CheckerInput.java b/java/com/google/gerrit/plugins/checks/api/CheckerInput.java
new file mode 100644
index 0000000..c0b20c4
--- /dev/null
+++ b/java/com/google/gerrit/plugins/checks/api/CheckerInput.java
@@ -0,0 +1,25 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.checks.api;
+
+import com.google.gerrit.extensions.restapi.DefaultInput;
+
+public class CheckerInput {
+  @DefaultInput public String name;
+  public String description;
+  public String url;
+  public String repository;
+  public CheckerStatus status;
+}
diff --git a/java/com/google/gerrit/plugins/checks/api/CheckerResource.java b/java/com/google/gerrit/plugins/checks/api/CheckerResource.java
new file mode 100644
index 0000000..da62429
--- /dev/null
+++ b/java/com/google/gerrit/plugins/checks/api/CheckerResource.java
@@ -0,0 +1,35 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.checks.api;
+
+import com.google.gerrit.extensions.restapi.RestResource;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.plugins.checks.Checker;
+import com.google.inject.TypeLiteral;
+
+public class CheckerResource implements RestResource {
+  public static final TypeLiteral<RestView<CheckerResource>> CHECKER_KIND =
+      new TypeLiteral<RestView<CheckerResource>>() {};
+
+  private final Checker checker;
+
+  public CheckerResource(Checker checker) {
+    this.checker = checker;
+  }
+
+  public Checker getChecker() {
+    return checker;
+  }
+}
diff --git a/java/com/google/gerrit/plugins/checks/api/CheckerStatus.java b/java/com/google/gerrit/plugins/checks/api/CheckerStatus.java
new file mode 100644
index 0000000..3a8f040
--- /dev/null
+++ b/java/com/google/gerrit/plugins/checks/api/CheckerStatus.java
@@ -0,0 +1,33 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.checks.api;
+
+/**
+ * Status of a configured checker.
+ *
+ * <p>This status is a property of the checker's configuration; not to be confused with {@code
+ * CheckState}, which is the state of an individual check performed by a checker against a specific
+ * change.
+ */
+public enum CheckerStatus {
+  /** The checker is enabled. */
+  ENABLED,
+
+  /**
+   * The checker is disabled, meaning its checks are not displayed alongside any changes, and the
+   * results are not considered when determining submit requirements.
+   */
+  DISABLED
+}
diff --git a/java/com/google/gerrit/plugins/checks/api/Checkers.java b/java/com/google/gerrit/plugins/checks/api/Checkers.java
new file mode 100644
index 0000000..b8a56c1
--- /dev/null
+++ b/java/com/google/gerrit/plugins/checks/api/Checkers.java
@@ -0,0 +1,62 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.checks.api;
+
+import com.google.gerrit.extensions.restapi.NotImplementedException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import java.util.List;
+
+public interface Checkers {
+  /**
+   * Look up a checker by ID.
+   *
+   * <p><strong>Note:</strong> This method eagerly reads the checker. Methods that mutate the
+   * checker do not necessarily re-read the checker. Therefore, calling a getter method on an
+   * instance after calling a mutation method on that same instance is not guaranteed to reflect the
+   * mutation. It is not recommended to store references to {@code checkerApi} instances.
+   *
+   * @param id any identifier supported by the REST API, including checker UUID.
+   * @return API for accessing the checker.
+   * @throws RestApiException if an error occurred.
+   */
+  CheckerApi id(String id) throws RestApiException;
+
+  /** Create a new checker. */
+  CheckerApi create(CheckerInput input) throws RestApiException;
+
+  /** Returns a list of all checkers, sorted by UUID. */
+  List<CheckerInfo> all() throws RestApiException;
+
+  /**
+   * A default implementation which allows source compatibility when adding new methods to the
+   * interface.
+   */
+  class NotImplemented implements Checkers {
+    @Override
+    public CheckerApi id(String id) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public CheckerApi create(CheckerInput input) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
+    public List<CheckerInfo> all() throws RestApiException {
+      throw new NotImplementedException();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/plugins/checks/api/CheckersCollection.java b/java/com/google/gerrit/plugins/checks/api/CheckersCollection.java
new file mode 100644
index 0000000..aa22d90
--- /dev/null
+++ b/java/com/google/gerrit/plugins/checks/api/CheckersCollection.java
@@ -0,0 +1,90 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.checks.api;
+
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestCollection;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.plugins.checks.AdministrateCheckersPermission;
+import com.google.gerrit.plugins.checks.Checker;
+import com.google.gerrit.plugins.checks.Checkers;
+import com.google.gerrit.server.AnonymousUser;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class CheckersCollection implements RestCollection<TopLevelResource, CheckerResource> {
+  private final Provider<CurrentUser> self;
+  private final PermissionBackend permissionBackend;
+  private final ListCheckers listCheckers;
+  private final Checkers checkers;
+  private final DynamicMap<RestView<CheckerResource>> views;
+  private final AdministrateCheckersPermission permission;
+
+  @Inject
+  public CheckersCollection(
+      Provider<CurrentUser> self,
+      PermissionBackend permissionBackend,
+      ListCheckers listCheckers,
+      Checkers checkers,
+      DynamicMap<RestView<CheckerResource>> views,
+      AdministrateCheckersPermission permission) {
+    this.self = self;
+    this.permissionBackend = permissionBackend;
+    this.listCheckers = listCheckers;
+    this.checkers = checkers;
+    this.views = views;
+    this.permission = permission;
+  }
+
+  @Override
+  public RestView<TopLevelResource> list() throws RestApiException {
+    return listCheckers;
+  }
+
+  @Override
+  public CheckerResource parse(TopLevelResource parent, IdString id)
+      throws AuthException, ResourceNotFoundException, PermissionBackendException, IOException,
+          ConfigInvalidException {
+    CurrentUser user = self.get();
+    if (user instanceof AnonymousUser) {
+      throw new AuthException("Authentication required");
+    } else if (!(user.isIdentifiedUser())) {
+      throw new ResourceNotFoundException(id);
+    }
+
+    permissionBackend.currentUser().check(permission);
+
+    Checker checker =
+        checkers.getChecker(id.get()).orElseThrow(() -> new ResourceNotFoundException(id));
+    return new CheckerResource(checker);
+  }
+
+  @Override
+  public DynamicMap<RestView<CheckerResource>> views() {
+    return views;
+  }
+}
diff --git a/java/com/google/gerrit/plugins/checks/api/CheckersImpl.java b/java/com/google/gerrit/plugins/checks/api/CheckersImpl.java
new file mode 100644
index 0000000..73e0f5d
--- /dev/null
+++ b/java/com/google/gerrit/plugins/checks/api/CheckersImpl.java
@@ -0,0 +1,72 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.checks.api;
+
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.List;
+
+@Singleton
+public class CheckersImpl implements Checkers {
+  private final CheckerApiImpl.Factory api;
+  private final CreateChecker createChecker;
+  private final ListCheckers listCheckers;
+  private final CheckersCollection checkers;
+
+  @Inject
+  CheckersImpl(
+      CheckerApiImpl.Factory api,
+      CreateChecker createChecker,
+      ListCheckers listCheckers,
+      CheckersCollection checkers) {
+    this.api = api;
+    this.createChecker = createChecker;
+    this.listCheckers = listCheckers;
+    this.checkers = checkers;
+  }
+
+  @Override
+  public CheckerApi id(String id) throws RestApiException {
+    try {
+      return api.create(checkers.parse(TopLevelResource.INSTANCE, IdString.fromDecoded(id)));
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve checker " + id, e);
+    }
+  }
+
+  @Override
+  public CheckerApi create(CheckerInput input) throws RestApiException {
+    try {
+      CheckerInfo info = createChecker.apply(TopLevelResource.INSTANCE, input).value();
+      return id(info.uuid);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot create checker " + input.name, e);
+    }
+  }
+
+  @Override
+  public List<CheckerInfo> all() throws RestApiException {
+    try {
+      return listCheckers.apply(TopLevelResource.INSTANCE);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot list all checkers ", e);
+    }
+  }
+}
diff --git a/java/com/google/gerrit/plugins/checks/api/CheckersRestApiServlet.java b/java/com/google/gerrit/plugins/checks/api/CheckersRestApiServlet.java
new file mode 100644
index 0000000..efda053
--- /dev/null
+++ b/java/com/google/gerrit/plugins/checks/api/CheckersRestApiServlet.java
@@ -0,0 +1,94 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.checks.api;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.gerrit.httpd.restapi.RestApiServlet;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletRequestWrapper;
+import javax.servlet.http.HttpServletResponse;
+
+@Singleton
+public class CheckersRestApiServlet extends RestApiServlet {
+  private static final long serialVersionUID = 1L;
+
+  @Inject
+  CheckersRestApiServlet(RestApiServlet.Globals globals, Provider<CheckersCollection> checkers) {
+    super(globals, checkers);
+  }
+
+  // TODO(dborowitz): Consider making
+  // RestApiServlet#service(HttpServletRequest, HttpServletResponse) non-final of overriding the
+  // non-HTTP overload.
+  @Override
+  public void service(ServletRequest servletRequest, ServletResponse servletResponse)
+      throws ServletException, IOException {
+    // This is...unfortunate. HttpPluginServlet (and/or ContextMapper) doesn't properly set the
+    // servlet path on the wrapped request. Based on what RestApiServlet produces for non-plugin
+    // requests, it should be:
+    //   contextPath = "/plugins/checks"
+    //   servletPath = "/checkers/"
+    //   pathInfo = checkerUuid
+    // Instead it does:
+    //   contextPath = "/plugins/checks"
+    //   servletPath = ""
+    //   pathInfo = "/checkers/" + checkerUuid
+    // This results in RestApiServlet splitting the pathInfo into ["", "checkers", checkerUuid], and
+    // it passes the "" to CheckersCollection#parse, which understandably, but unfortunately, fails.
+    //
+    // This frankly seems like a bug that should be fixed, but it would quite likely break existing
+    // plugins in confusing ways. So, we work around it by introducing our own request wrapper with
+    // the correct paths.
+    HttpServletRequest req = (HttpServletRequest) servletRequest;
+
+    String pathInfo = req.getPathInfo();
+    String correctServletPath = "/checkers/";
+
+    // Ensure actual request object matches the format explained above.
+    checkState(
+        req.getContextPath().endsWith("/checks"),
+        "unexpected context path: %s",
+        req.getContextPath());
+    checkState(req.getServletPath().isEmpty(), "unexpected servlet path: %s", req.getServletPath());
+    checkState(
+        req.getPathInfo().startsWith(correctServletPath),
+        "unexpected servlet path: %s",
+        req.getServletPath());
+
+    String fixedPathInfo = pathInfo.substring(correctServletPath.length());
+    HttpServletRequestWrapper wrapped =
+        new HttpServletRequestWrapper(req) {
+          @Override
+          public String getServletPath() {
+            return correctServletPath;
+          }
+
+          @Override
+          public String getPathInfo() {
+            return fixedPathInfo;
+          }
+        };
+
+    super.service(wrapped, (HttpServletResponse) servletResponse);
+  }
+}
diff --git a/java/com/google/gerrit/plugins/checks/api/CreateChecker.java b/java/com/google/gerrit/plugins/checks/api/CreateChecker.java
new file mode 100644
index 0000000..16a22f8
--- /dev/null
+++ b/java/com/google/gerrit/plugins/checks/api/CreateChecker.java
@@ -0,0 +1,120 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.checks.api;
+
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestCollectionModifyView;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.plugins.checks.AdministrateCheckersPermission;
+import com.google.gerrit.plugins.checks.Checker;
+import com.google.gerrit.plugins.checks.CheckerCreation;
+import com.google.gerrit.plugins.checks.CheckerJson;
+import com.google.gerrit.plugins.checks.CheckerName;
+import com.google.gerrit.plugins.checks.CheckerUpdate;
+import com.google.gerrit.plugins.checks.CheckerUrl;
+import com.google.gerrit.plugins.checks.CheckerUuid;
+import com.google.gerrit.plugins.checks.CheckersUpdate;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.UserInitiated;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gwtorm.server.OrmDuplicateKeyException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class CreateChecker
+    implements RestCollectionModifyView<TopLevelResource, CheckerResource, CheckerInput> {
+  private final PermissionBackend permissionBackend;
+  private final Provider<CheckersUpdate> checkersUpdate;
+  private final CheckerJson checkerJson;
+  private final AdministrateCheckersPermission permission;
+  private final ProjectCache projectCache;
+
+  @Inject
+  public CreateChecker(
+      PermissionBackend permissionBackend,
+      @UserInitiated Provider<CheckersUpdate> checkersUpdate,
+      CheckerJson checkerJson,
+      AdministrateCheckersPermission permission,
+      ProjectCache projectCache) {
+    this.permissionBackend = permissionBackend;
+    this.checkersUpdate = checkersUpdate;
+    this.checkerJson = checkerJson;
+    this.permission = permission;
+    this.projectCache = projectCache;
+  }
+
+  @Override
+  public Response<CheckerInfo> apply(TopLevelResource parentResource, CheckerInput input)
+      throws RestApiException, PermissionBackendException, IOException, ConfigInvalidException,
+          OrmDuplicateKeyException {
+    permissionBackend.currentUser().check(permission);
+
+    if (input == null) {
+      input = new CheckerInput();
+    }
+
+    String name = CheckerName.clean(input.name);
+    if (name.isEmpty()) {
+      throw new BadRequestException("name is required");
+    }
+    Project.NameKey repository = resolveRepository(input.repository);
+
+    String checkerUuid = CheckerUuid.make(name);
+    CheckerCreation.Builder checkerCreationBuilder =
+        CheckerCreation.builder()
+            .setCheckerUuid(checkerUuid)
+            .setName(name)
+            .setRepository(repository);
+    CheckerUpdate.Builder checkerUpdateBuilder = CheckerUpdate.builder();
+    if (input.description != null && !input.description.trim().isEmpty()) {
+      checkerUpdateBuilder.setDescription(input.description.trim());
+    }
+    if (input.url != null) {
+      checkerUpdateBuilder.setUrl(CheckerUrl.clean(input.url));
+    }
+    if (input.status != null) {
+      checkerUpdateBuilder.setStatus(input.status);
+    }
+    Checker checker =
+        checkersUpdate
+            .get()
+            .createChecker(checkerCreationBuilder.build(), checkerUpdateBuilder.build());
+    return Response.created(checkerJson.format(checker));
+  }
+
+  private Project.NameKey resolveRepository(String repository)
+      throws BadRequestException, UnprocessableEntityException, IOException {
+    if (repository == null || repository.trim().isEmpty()) {
+      throw new BadRequestException("repository is required");
+    }
+
+    ProjectState projectState = projectCache.checkedGet(new Project.NameKey(repository.trim()));
+    if (projectState == null) {
+      throw new UnprocessableEntityException(String.format("repository %s not found", repository));
+    }
+
+    return projectState.getNameKey();
+  }
+}
diff --git a/java/com/google/gerrit/plugins/checks/api/GetChecker.java b/java/com/google/gerrit/plugins/checks/api/GetChecker.java
new file mode 100644
index 0000000..9a84a91
--- /dev/null
+++ b/java/com/google/gerrit/plugins/checks/api/GetChecker.java
@@ -0,0 +1,35 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.checks.api;
+
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.plugins.checks.CheckerJson;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+@Singleton
+public class GetChecker implements RestReadView<CheckerResource> {
+  private final CheckerJson checkerJson;
+
+  @Inject
+  public GetChecker(CheckerJson checkerJson) {
+    this.checkerJson = checkerJson;
+  }
+
+  @Override
+  public CheckerInfo apply(CheckerResource resource) {
+    return checkerJson.format(resource.getChecker());
+  }
+}
diff --git a/java/com/google/gerrit/plugins/checks/api/HttpModule.java b/java/com/google/gerrit/plugins/checks/api/HttpModule.java
new file mode 100644
index 0000000..bd5c447
--- /dev/null
+++ b/java/com/google/gerrit/plugins/checks/api/HttpModule.java
@@ -0,0 +1,52 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.checks.api;
+
+import static com.google.gerrit.plugins.checks.api.CheckerResource.CHECKER_KIND;
+
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.RestApiModule;
+import com.google.gerrit.httpd.plugins.HttpPluginModule;
+
+public class HttpModule extends HttpPluginModule {
+  @Override
+  protected void configureServlets() {
+    bind(CheckersCollection.class);
+
+    bind(Checkers.class).to(CheckersImpl.class);
+
+    serveRegex("^/checkers/(.*)$").with(CheckersRestApiServlet.class);
+
+    install(
+        new RestApiModule() {
+          @Override
+          public void configure() {
+            DynamicMap.mapOf(binder(), CHECKER_KIND);
+            postOnCollection(CHECKER_KIND).to(CreateChecker.class);
+            get(CHECKER_KIND).to(GetChecker.class);
+            post(CHECKER_KIND).to(UpdateChecker.class);
+          }
+        });
+
+    install(
+        new FactoryModule() {
+          @Override
+          public void configure() {
+            factory(CheckerApiImpl.Factory.class);
+          }
+        });
+  }
+}
diff --git a/java/com/google/gerrit/plugins/checks/api/ListCheckers.java b/java/com/google/gerrit/plugins/checks/api/ListCheckers.java
new file mode 100644
index 0000000..a912d6e
--- /dev/null
+++ b/java/com/google/gerrit/plugins/checks/api/ListCheckers.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.checks.api;
+
+import static java.util.stream.Collectors.toList;
+
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.plugins.checks.AdministrateCheckersPermission;
+import com.google.gerrit.plugins.checks.CheckerJson;
+import com.google.gerrit.plugins.checks.Checkers;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.List;
+
+@Singleton
+public class ListCheckers implements RestReadView<TopLevelResource> {
+  private final PermissionBackend permissionBackend;
+  private final Checkers checkers;
+  private final CheckerJson checkerJson;
+  private final AdministrateCheckersPermission permission;
+
+  @Inject
+  public ListCheckers(
+      PermissionBackend permissionBackend,
+      Checkers checkers,
+      CheckerJson checkerJson,
+      AdministrateCheckersPermission permission) {
+    this.permissionBackend = permissionBackend;
+    this.checkers = checkers;
+    this.checkerJson = checkerJson;
+    this.permission = permission;
+  }
+
+  @Override
+  public List<CheckerInfo> apply(TopLevelResource resource)
+      throws RestApiException, PermissionBackendException, IOException {
+    permissionBackend.currentUser().check(permission);
+
+    return checkers.listCheckers().stream().map(checkerJson::format).collect(toList());
+  }
+}
diff --git a/java/com/google/gerrit/plugins/checks/api/UpdateChecker.java b/java/com/google/gerrit/plugins/checks/api/UpdateChecker.java
new file mode 100644
index 0000000..e1f2245
--- /dev/null
+++ b/java/com/google/gerrit/plugins/checks/api/UpdateChecker.java
@@ -0,0 +1,118 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.checks.api;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.plugins.checks.AdministrateCheckersPermission;
+import com.google.gerrit.plugins.checks.Checker;
+import com.google.gerrit.plugins.checks.CheckerJson;
+import com.google.gerrit.plugins.checks.CheckerName;
+import com.google.gerrit.plugins.checks.CheckerUpdate;
+import com.google.gerrit.plugins.checks.CheckerUrl;
+import com.google.gerrit.plugins.checks.CheckersUpdate;
+import com.google.gerrit.plugins.checks.NoSuchCheckerException;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.UserInitiated;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import javax.inject.Singleton;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class UpdateChecker implements RestModifyView<CheckerResource, CheckerInput> {
+  private final PermissionBackend permissionBackend;
+  private final Provider<CheckersUpdate> checkersUpdate;
+  private final CheckerJson checkerJson;
+  private final ProjectCache projectCache;
+
+  private final AdministrateCheckersPermission permission;
+
+  @Inject
+  public UpdateChecker(
+      PermissionBackend permissionBackend,
+      @UserInitiated Provider<CheckersUpdate> checkersUpdate,
+      CheckerJson checkerJson,
+      AdministrateCheckersPermission permission,
+      ProjectCache projectCache) {
+    this.permissionBackend = permissionBackend;
+    this.checkersUpdate = checkersUpdate;
+    this.checkerJson = checkerJson;
+    this.permission = permission;
+    this.projectCache = projectCache;
+  }
+
+  @Override
+  public CheckerInfo apply(CheckerResource resource, CheckerInput input)
+      throws RestApiException, PermissionBackendException, NoSuchCheckerException, IOException,
+          ConfigInvalidException {
+    permissionBackend.currentUser().check(permission);
+
+    CheckerUpdate.Builder checkerUpdateBuilder = CheckerUpdate.builder();
+
+    if (input.name != null) {
+      String newName = CheckerName.clean(input.name);
+      if (newName.isEmpty()) {
+        throw new BadRequestException("name cannot be unset");
+      }
+      checkerUpdateBuilder.setName(newName);
+    }
+
+    if (input.description != null) {
+      checkerUpdateBuilder.setDescription(Strings.nullToEmpty(input.description).trim());
+    }
+
+    if (input.url != null) {
+      checkerUpdateBuilder.setUrl(CheckerUrl.clean(input.url));
+    }
+
+    if (input.repository != null) {
+      Project.NameKey repository = resolveRepository(input.repository);
+      checkerUpdateBuilder.setRepository(repository);
+    }
+
+    if (input.status != null) {
+      checkerUpdateBuilder.setStatus(input.status);
+    }
+
+    Checker updatedChecker =
+        checkersUpdate
+            .get()
+            .updateChecker(resource.getChecker().getUuid(), checkerUpdateBuilder.build());
+    return checkerJson.format(updatedChecker);
+  }
+
+  private Project.NameKey resolveRepository(String repository)
+      throws BadRequestException, UnprocessableEntityException, IOException {
+    if (repository == null || repository.trim().isEmpty()) {
+      throw new BadRequestException("repository cannot be unset");
+    }
+
+    ProjectState projectState = projectCache.checkedGet(new Project.NameKey(repository.trim()));
+    if (projectState == null) {
+      throw new UnprocessableEntityException(String.format("repository %s not found", repository));
+    }
+
+    return projectState.getNameKey();
+  }
+}
diff --git a/java/com/google/gerrit/plugins/checks/db/CheckerConfig.java b/java/com/google/gerrit/plugins/checks/db/CheckerConfig.java
new file mode 100644
index 0000000..cd5c99e
--- /dev/null
+++ b/java/com/google/gerrit/plugins/checks/db/CheckerConfig.java
@@ -0,0 +1,366 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.checks.db;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Strings;
+import com.google.gerrit.plugins.checks.Checker;
+import com.google.gerrit.plugins.checks.CheckerCreation;
+import com.google.gerrit.plugins.checks.CheckerName;
+import com.google.gerrit.plugins.checks.CheckerRef;
+import com.google.gerrit.plugins.checks.CheckerUpdate;
+import com.google.gerrit.plugins.checks.CheckerUuid;
+import com.google.gerrit.plugins.checks.Checkers;
+import com.google.gerrit.plugins.checks.CheckersUpdate;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.git.meta.VersionedMetaData;
+import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.gwtorm.server.OrmDuplicateKeyException;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.Arrays;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevSort;
+
+/**
+ * A representation of a checker in NoteDb.
+ *
+ * <p>Checkers in NoteDb can be created by following the descriptions of {@link
+ * #createForNewChecker(Project.NameKey, Repository, CheckerCreation)}. For reading checkers from
+ * NoteDb or updating them, refer to {@link #loadForChecker(Project.NameKey, Repository, String)}.
+ *
+ * <p><strong>Note:</strong> Any modification (checker creation or update) only becomes permanent
+ * (and hence written to NoteDb) if {@link #commit(MetaDataUpdate)} is called.
+ *
+ * <p><strong>Warning:</strong> This class is a low-level API for checkers in NoteDb. Most code
+ * which deals with checkers should use {@link Checkers} or {@link CheckersUpdate} instead.
+ *
+ * <h2>Internal details</h2>
+ *
+ * <p>Each checker is represented by a commit on a branch as defined by {@link
+ * CheckerRef#refsCheckers(String)}. Previous versions of the checker exist as older commits on the
+ * same branch and can be reached by following along the parent references. New commits for updates
+ * are only created if a real modification occurs.
+ *
+ * <p>Within each commit, the properties of a checker are stored in <em>checker.config</em> file
+ * (further specified by {@link CheckerConfigEntry}). The <em>checker.config</em> file is formatted
+ * as a JGit {@link Config} file.
+ */
+@VisibleForTesting
+public class CheckerConfig extends VersionedMetaData {
+  @VisibleForTesting public static final String CHECKER_CONFIG_FILE = "checker.config";
+
+  /**
+   * Creates a {@code CheckerConfig} for a new checker from the {@code CheckerCreation} blueprint.
+   * Further, optional properties can be specified by setting an {@code CheckerUpdate} via {@link
+   * #setCheckerUpdate(CheckerUpdate)} on the returned {@code CheckerConfig}.
+   *
+   * <p><strong>Note:</strong> The returned {@code CheckerConfig} has to be committed via {@link
+   * #commit(MetaDataUpdate)} in order to create the checker for real.
+   *
+   * @param projectName the name of the project which holds the NoteDb commits for checkers
+   * @param repository the repository which holds the NoteDb commits for checkers
+   * @param checkerCreation a {@code CheckerCreation} specifying all properties which are required
+   *     for a new checker
+   * @return a {@code CheckerConfig} for a checker creation
+   * @throws IOException if the repository can't be accessed for some reason
+   * @throws ConfigInvalidException if a checker with the same UUID already exists but can't be read
+   *     due to an invalid format
+   * @throws OrmDuplicateKeyException if a checker with the same UUID already exists
+   */
+  public static CheckerConfig createForNewChecker(
+      Project.NameKey projectName, Repository repository, CheckerCreation checkerCreation)
+      throws IOException, ConfigInvalidException, OrmDuplicateKeyException {
+    CheckerConfig checkerConfig = new CheckerConfig(checkerCreation.getCheckerUuid());
+    checkerConfig.load(projectName, repository);
+    checkerConfig.setCheckerCreation(checkerCreation);
+    return checkerConfig;
+  }
+
+  /**
+   * Creates a {@code CheckerConfig} for an existing checker.
+   *
+   * <p>The checker is automatically loaded within this method and can be accessed via {@link
+   * #getLoadedChecker()}.
+   *
+   * <p>It's safe to call this method for non-existing checkers. In that case, {@link
+   * #getLoadedChecker()} won't return any checker. Thus, the existence of a checker can be easily
+   * tested.
+   *
+   * <p>The checker represented by the returned {@code CheckerConfig} can be updated by setting an
+   * {@code CheckerUpdate} via {@link #setCheckerUpdate(CheckerUpdate)} and committing the {@code
+   * CheckerConfig} via {@link #commit(MetaDataUpdate)}.
+   *
+   * @param projectName the name of the project which holds the NoteDb commits for checkers
+   * @param repository the repository which holds the NoteDb commits for checkers
+   * @param checkerUuid the UUID of the checker
+   * @return a {@code CheckerConfig} for the checker with the specified UUID
+   * @throws IOException if the repository can't be accessed for some reason
+   * @throws ConfigInvalidException if the checker exists but can't be read due to an invalid format
+   */
+  public static CheckerConfig loadForChecker(
+      Project.NameKey projectName, Repository repository, String checkerUuid)
+      throws IOException, ConfigInvalidException {
+    CheckerConfig checkerConfig = new CheckerConfig(checkerUuid);
+    checkerConfig.load(projectName, repository);
+    return checkerConfig;
+  }
+
+  private final String checkerUuid;
+  private final String ref;
+
+  private Optional<Checker> loadedChecker = Optional.empty();
+  private Optional<CheckerCreation> checkerCreation = Optional.empty();
+  private Optional<CheckerUpdate> checkerUpdate = Optional.empty();
+  private Optional<Checker.Builder> updatedCheckerBuilder = Optional.empty();
+  private Config config;
+  private boolean isLoaded = false;
+
+  private CheckerConfig(String checkerUuid) {
+    this.checkerUuid = CheckerUuid.checkUuid(checkerUuid);
+    this.ref = CheckerRef.refsCheckers(checkerUuid);
+  }
+
+  /**
+   * Returns the checker loaded from NoteDb.
+   *
+   * <p>If not any NoteDb commits exist for the checker represented by this {@code CheckerConfig},
+   * no checker is returned.
+   *
+   * <p>After {@link #commit(MetaDataUpdate)} was called on this {@code CheckerConfig}, this method
+   * returns a checker which is in line with the latest NoteDb commit for this checker. So, after
+   * creating a {@code CheckerConfig} for a new checker and committing it, this method can be used
+   * to retrieve a representation of the created checker. The same holds for the representation of
+   * an updated checker.
+   *
+   * @return the loaded checker, or an empty {@code Optional} if the checker doesn't exist
+   */
+  public Optional<Checker> getLoadedChecker() {
+    checkLoaded();
+
+    if (updatedCheckerBuilder.isPresent()) {
+      // There have been updates to the checker that have not been applied to the loaded checker
+      // yet, apply them now. This has to be done here because in the onSave(CommitBuilder) method
+      // where the checker updates are committed we do not know the new SHA1 for the ref state
+      // yet.
+      loadedChecker = Optional.of(updatedCheckerBuilder.get().setRefState(revision).build());
+      updatedCheckerBuilder = Optional.empty();
+    }
+
+    return loadedChecker;
+  }
+
+  /**
+   * Specifies how the current checker should be updated.
+   *
+   * <p>If the checker is newly created, the {@code CheckerUpdate} can be used to specify optional
+   * properties.
+   *
+   * <p><strong>Note:</strong> This method doesn't perform the update. It only contains the
+   * instructions for the update. To apply the update for real and write the result back to NoteDb,
+   * call {@link #commit(MetaDataUpdate)} on this {@code CheckerConfig}.
+   *
+   * @param checkerUpdate an {@code CheckerUpdate} outlining the modifications which should be
+   *     applied
+   */
+  public void setCheckerUpdate(CheckerUpdate checkerUpdate) {
+    this.checkerUpdate = Optional.of(checkerUpdate);
+  }
+
+  private void setCheckerCreation(CheckerCreation checkerCreation) throws OrmDuplicateKeyException {
+    checkLoaded();
+    if (loadedChecker.isPresent()) {
+      throw new OrmDuplicateKeyException(String.format("Checker %s already exists", checkerUuid));
+    }
+
+    this.checkerCreation = Optional.of(checkerCreation);
+  }
+
+  @Override
+  protected String getRefName() {
+    return ref;
+  }
+
+  @VisibleForTesting
+  public Config getConfigForTesting() {
+    return config;
+  }
+
+  @Override
+  protected void onLoad() throws IOException, ConfigInvalidException {
+    if (revision != null) {
+      rw.reset();
+      rw.markStart(revision);
+      rw.sort(RevSort.REVERSE);
+      RevCommit earliestCommit = rw.next();
+      Timestamp createdOn = new Timestamp(earliestCommit.getCommitTime() * 1000L);
+      Timestamp updatedOn = new Timestamp(rw.parseCommit(revision).getCommitTime() * 1000L);
+
+      config = readConfig(CHECKER_CONFIG_FILE);
+      loadedChecker =
+          Optional.of(createFrom(checkerUuid, config, createdOn, updatedOn, revision.toObjectId()));
+    }
+
+    isLoaded = true;
+  }
+
+  @Override
+  protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
+    checkLoaded();
+    if (!checkerCreation.isPresent() && !checkerUpdate.isPresent()) {
+      // Checker was neither created nor changed. -> A new commit isn't necessary.
+      return false;
+    }
+
+    ensureThatMandatoryPropertiesAreSet();
+
+    // Commit timestamps are internally truncated to seconds. To return the correct 'createdOn' time
+    // for new checkers, we explicitly need to truncate the timestamp here.
+    Timestamp commitTimestamp =
+        TimeUtil.truncateToSecond(
+            checkerUpdate.flatMap(CheckerUpdate::getUpdatedOn).orElseGet(TimeUtil::nowTs));
+    commit.setAuthor(new PersonIdent(commit.getAuthor(), commitTimestamp));
+    commit.setCommitter(new PersonIdent(commit.getCommitter(), commitTimestamp));
+
+    updatedCheckerBuilder = Optional.of(updateChecker(commitTimestamp));
+
+    String commitMessage = createCommitMessage(loadedChecker, checkerUpdate);
+    commit.setMessage(commitMessage);
+
+    checkerCreation = Optional.empty();
+    checkerUpdate = Optional.empty();
+    return true;
+  }
+
+  private void ensureThatMandatoryPropertiesAreSet() throws ConfigInvalidException {
+    if (getNewName().equals(Optional.of(""))) {
+      throw new ConfigInvalidException(
+          String.format("Name of the checker %s must be defined", checkerUuid));
+    }
+
+    if (getNewRepository().equals(Optional.of(""))) {
+      throw new ConfigInvalidException(
+          String.format("Repository of the checker %s must be defined", checkerUuid));
+    }
+  }
+
+  private void checkLoaded() {
+    checkState(isLoaded, "Checker %s not loaded yet", checkerUuid);
+  }
+
+  private Optional<String> getNewName() {
+    if (checkerUpdate.isPresent()) {
+      return checkerUpdate.get().getName().map(CheckerName::clean);
+    }
+    if (checkerCreation.isPresent()) {
+      return Optional.of(CheckerName.clean(checkerCreation.get().getName()));
+    }
+    return Optional.empty();
+  }
+
+  private Optional<String> getNewRepository() {
+    if (checkerUpdate.isPresent()) {
+      return checkerUpdate
+          .get()
+          .getRepository()
+          .map(Project.NameKey::get)
+          .map(Strings::nullToEmpty)
+          .map(String::trim);
+    }
+    if (checkerCreation.isPresent()) {
+      return Optional.of(Strings.nullToEmpty(checkerCreation.get().getRepository().get()).trim());
+    }
+    return Optional.empty();
+  }
+
+  private Checker.Builder updateChecker(Timestamp commitTimestamp)
+      throws IOException, ConfigInvalidException {
+    Config config = updateCheckerProperties();
+    Timestamp createdOn = loadedChecker.map(Checker::getCreatedOn).orElse(commitTimestamp);
+    return createBuilderFrom(checkerUuid, config, createdOn, commitTimestamp);
+  }
+
+  private Config updateCheckerProperties() throws IOException, ConfigInvalidException {
+    Config config = readConfig(CHECKER_CONFIG_FILE);
+    checkerCreation.ifPresent(
+        checkerCreation ->
+            Arrays.stream(CheckerConfigEntry.values())
+                .forEach(configEntry -> configEntry.initNewConfig(config, checkerCreation)));
+    checkerUpdate.ifPresent(
+        checkerUpdate ->
+            Arrays.stream(CheckerConfigEntry.values())
+                .forEach(configEntry -> configEntry.updateConfigValue(config, checkerUpdate)));
+    saveConfig(CHECKER_CONFIG_FILE, config);
+    return config;
+  }
+
+  private static Checker.Builder createBuilderFrom(
+      String checkerUuid, Config config, Timestamp createdOn, Timestamp updatedOn)
+      throws ConfigInvalidException {
+    Checker.Builder checker = Checker.builder(checkerUuid);
+    for (CheckerConfigEntry configEntry : CheckerConfigEntry.values()) {
+      configEntry.readFromConfig(checkerUuid, checker, config);
+    }
+    checker.setCreatedOn(createdOn).setUpdatedOn(updatedOn);
+    return checker;
+  }
+
+  private static Checker createFrom(
+      String checkerUuid,
+      Config config,
+      Timestamp createdOn,
+      Timestamp updatedOn,
+      ObjectId refState)
+      throws ConfigInvalidException {
+    return createBuilderFrom(checkerUuid, config, createdOn, updatedOn)
+        .setRefState(refState)
+        .build();
+  }
+
+  private static String createCommitMessage(
+      Optional<Checker> originalChecker, Optional<CheckerUpdate> checkerUpdate) {
+    Optional<String> newCheckerName = checkerUpdate.flatMap(CheckerUpdate::getName);
+    String summaryLine = originalChecker.isPresent() ? "Update checker" : "Create checker";
+    Optional<String> footerForRename = getFooterForRename(originalChecker, newCheckerName);
+    if (footerForRename.isPresent()) {
+      return summaryLine + "\n\n" + footerForRename.get();
+    }
+    return summaryLine;
+  }
+
+  private static Optional<String> getFooterForRename(
+      Optional<Checker> originalChecker, Optional<String> newCheckerName) {
+    if (!originalChecker.isPresent() || !newCheckerName.isPresent()) {
+      return Optional.empty();
+    }
+
+    String originalName = originalChecker.get().getName();
+    String newName = newCheckerName.get();
+    if (originalName.equals(newName)) {
+      return Optional.empty();
+    }
+    return Optional.of("Rename from " + originalName + " to " + newName);
+  }
+}
diff --git a/java/com/google/gerrit/plugins/checks/db/CheckerConfigEntry.java b/java/com/google/gerrit/plugins/checks/db/CheckerConfigEntry.java
new file mode 100644
index 0000000..30b2a2b
--- /dev/null
+++ b/java/com/google/gerrit/plugins/checks/db/CheckerConfigEntry.java
@@ -0,0 +1,236 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.checks.db;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.plugins.checks.Checker;
+import com.google.gerrit.plugins.checks.CheckerCreation;
+import com.google.gerrit.plugins.checks.CheckerUpdate;
+import com.google.gerrit.plugins.checks.api.CheckerStatus;
+import com.google.gerrit.reviewdb.client.Project;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * A basic property of a checker.
+ *
+ * <p>Each property knows how to read and write its value from/to a JGit {@link Config} file.
+ */
+enum CheckerConfigEntry {
+  /**
+   * The name of a checker. This property is equivalent to {@link Checker#getName()}.
+   *
+   * <p>This is a mandatory property.
+   */
+  NAME("name") {
+    @Override
+    void readFromConfig(String checkerUuid, Checker.Builder checker, Config config)
+        throws ConfigInvalidException {
+      String name = config.getString(SECTION_NAME, null, super.keyName);
+      // An empty name is invalid in NoteDb; CheckerConfig will refuse to store it
+      if (name == null) {
+        throw new ConfigInvalidException(String.format("name of checker %s not set", checkerUuid));
+      }
+      checker.setName(name);
+    }
+
+    @Override
+    void initNewConfig(Config config, CheckerCreation checkerCreation) {
+      String name = checkerCreation.getName();
+      config.setString(SECTION_NAME, null, super.keyName, name);
+    }
+
+    @Override
+    void updateConfigValue(Config config, CheckerUpdate checkerUpdate) {
+      checkerUpdate
+          .getName()
+          .ifPresent(name -> config.setString(SECTION_NAME, null, super.keyName, name));
+    }
+  },
+
+  /**
+   * The description of a checker. This property is equivalent to {@link Checker#getDescription()}.
+   *
+   * <p>It defaults to {@code null} if not set.
+   */
+  DESCRIPTION("description") {
+    @Override
+    void readFromConfig(String checkerUuid, Checker.Builder checker, Config config) {
+      String description = config.getString(SECTION_NAME, null, super.keyName);
+      if (!Strings.isNullOrEmpty(description)) {
+        checker.setDescription(description);
+      }
+    }
+
+    @Override
+    void initNewConfig(Config config, CheckerCreation checkerCreation) {
+      // Do nothing. Description key will be set by updateConfigValue.
+    }
+
+    @Override
+    void updateConfigValue(Config config, CheckerUpdate checkerUpdate) {
+      checkerUpdate
+          .getDescription()
+          .ifPresent(
+              description -> {
+                if (Strings.isNullOrEmpty(description)) {
+                  config.unset(SECTION_NAME, null, super.keyName);
+                } else {
+                  config.setString(SECTION_NAME, null, super.keyName, description);
+                }
+              });
+    }
+  },
+
+  /**
+   * The URL of a checker. This property is equivalent to {@link Checker#getUrl()}.
+   *
+   * <p>It defaults to {@code null} if not set.
+   */
+  URL("url") {
+    @Override
+    void readFromConfig(String checkerUuid, Checker.Builder checker, Config config) {
+      String url = config.getString(SECTION_NAME, null, super.keyName);
+      if (!Strings.isNullOrEmpty(url)) {
+        checker.setUrl(url);
+      }
+    }
+
+    @Override
+    void initNewConfig(Config config, CheckerCreation checkerCreation) {
+      // Do nothing. URL key will be set by updateConfigValue.
+    }
+
+    @Override
+    void updateConfigValue(Config config, CheckerUpdate checkerUpdate) {
+      checkerUpdate
+          .getUrl()
+          .ifPresent(
+              url -> {
+                if (Strings.isNullOrEmpty(url)) {
+                  config.unset(SECTION_NAME, null, super.keyName);
+                } else {
+                  config.setString(SECTION_NAME, null, super.keyName, url);
+                }
+              });
+    }
+  },
+
+  /**
+   * The repository for which the checker applies. This property is equivalent to {@link
+   * Checker#getRepository()}.
+   *
+   * <p>This is a mandatory property.
+   */
+  REPOSITORY("repository") {
+    @Override
+    void readFromConfig(String checkerUuid, Checker.Builder checker, Config config)
+        throws ConfigInvalidException {
+      String repository = config.getString(SECTION_NAME, null, super.keyName);
+      // An empty repository is invalid in NoteDb; CheckerConfig will refuse to store it
+      if (repository == null) {
+        throw new ConfigInvalidException(
+            String.format("repository of checker %s not set", checkerUuid));
+      }
+      checker.setRepository(new Project.NameKey(repository));
+    }
+
+    @Override
+    void initNewConfig(Config config, CheckerCreation checkerCreation) {
+      String repository = checkerCreation.getRepository().get();
+      config.setString(SECTION_NAME, null, super.keyName, repository);
+    }
+
+    @Override
+    void updateConfigValue(Config config, CheckerUpdate checkerUpdate) {
+      checkerUpdate
+          .getRepository()
+          .ifPresent(
+              repository -> config.setString(SECTION_NAME, null, super.keyName, repository.get()));
+    }
+  },
+
+  STATUS("status") {
+    @Override
+    void readFromConfig(String checkerUuid, Checker.Builder checker, Config config)
+        throws ConfigInvalidException {
+      String value = config.getString(SECTION_NAME, null, super.keyName);
+      if (value == null) {
+        throw new ConfigInvalidException(
+            String.format("status of checker %s not set", checkerUuid));
+      }
+      checker.setStatus(config.getEnum(SECTION_NAME, null, super.keyName, CheckerStatus.ENABLED));
+    }
+
+    @Override
+    void initNewConfig(Config config, CheckerCreation checkerCreation) {
+      // New checkers default to enabled.
+      config.setEnum(SECTION_NAME, null, super.keyName, CheckerStatus.ENABLED);
+    }
+
+    @Override
+    void updateConfigValue(Config config, CheckerUpdate checkerUpdate) {
+      checkerUpdate
+          .getStatus()
+          .ifPresent(status -> config.setEnum(SECTION_NAME, null, super.keyName, status));
+    }
+  };
+
+  private static final String SECTION_NAME = "checker";
+
+  private final String keyName;
+
+  CheckerConfigEntry(String keyName) {
+    this.keyName = keyName;
+  }
+
+  /**
+   * Reads the corresponding property of this {@code CheckerConfigEntry} from the given {@code
+   * Config}. The read value is written to the corresponding property of {@code Checker.Builder}.
+   *
+   * @param checkerUuid the UUID of the checker (necessary for helpful error messages)
+   * @param checker the {@code Checker.Builder} whose property value should be set
+   * @param config the {@code Config} from which the value of the property should be read
+   * @throws ConfigInvalidException if the property has an unexpected value
+   */
+  abstract void readFromConfig(String checkerUuid, Checker.Builder checker, Config config)
+      throws ConfigInvalidException;
+
+  /**
+   * Initializes the corresponding property of this {@code CheckerConfigEntry} in the given {@code
+   * Config}.
+   *
+   * <p>If the specified {@code CheckerCreation} has an entry for the property, that value is used.
+   * If not, the default value for the property is set. In any case, an existing entry for the
+   * property in the {@code Config} will be overwritten.
+   *
+   * @param config a new {@code Config}, typically without an entry for the property
+   * @param checkerCreation an {@code CheckerCreation} detailing the initial value of mandatory
+   *     checker properties
+   */
+  abstract void initNewConfig(Config config, CheckerCreation checkerCreation);
+
+  /**
+   * Updates the corresponding property of this {@code CheckerConfigEntry} in the given {@code
+   * Config} if the {@code CheckerUpdate} mentions a modification.
+   *
+   * <p>This call is a no-op if the {@code CheckerUpdate} doesn't contain a modification for the
+   * property.
+   *
+   * @param config a {@code Config} for which the property should be updated
+   * @param checkerUpdate an {@code CheckerUpdate} detailing the modifications on a checker
+   */
+  abstract void updateConfigValue(Config config, CheckerUpdate checkerUpdate);
+}
diff --git a/java/com/google/gerrit/plugins/checks/db/CheckersByRepositoryNotes.java b/java/com/google/gerrit/plugins/checks/db/CheckersByRepositoryNotes.java
new file mode 100644
index 0000000..be5c322
--- /dev/null
+++ b/java/com/google/gerrit/plugins/checks/db/CheckersByRepositoryNotes.java
@@ -0,0 +1,427 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.checks.db;
+
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableSortedSet.toImmutableSortedSet;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Comparator.naturalOrder;
+import static java.util.Objects.requireNonNull;
+import static java.util.stream.Collectors.joining;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Joiner;
+import com.google.common.base.Splitter;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableSortedSet;
+import com.google.common.flogger.FluentLogger;
+import com.google.common.hash.Hashing;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.plugins.checks.CheckerRef;
+import com.google.gerrit.plugins.checks.CheckerUuid;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.git.meta.VersionedMetaData;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevTree;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * {@link VersionedMetaData} subclass to read/update the repository to checkers map.
+ *
+ * <p>The map of repository to checkers is stored in the {@code refs/meta/checkers} notes branch in
+ * the {@code All-Projects} repository. The note ID is a SHA1 that is computed from the repository
+ * name. The node content is a plain list of checker UUIDs, one checker UUID per line.
+ *
+ * <p>This is a low-level API. Reading of the repository to checkers map should be done through
+ * {@link
+ * com.google.gerrit.plugins.checks.Checkers#checkersOf(com.google.gerrit.reviewdb.client.Project.NameKey)}.
+ * Updates to the repository to checkers map are done automatically when creating/updating checkers
+ * through {@link com.google.gerrit.plugins.checks.CheckersUpdate}.
+ *
+ * <p>On load the note map from {@code refs/meta/checkers} is read, but the checker lists are not
+ * parsed yet (see {@link #onLoad()}).
+ *
+ * <p>After loading the note map callers can access the checker list for a single repository. Only
+ * now the requested checker list is parsed.
+ *
+ * <p>After loading the note map callers can stage various updates for the repository to checker map
+ * (insert, update, remove).
+ *
+ * <p>On save the staged updates for the repository to checkers map are performed (see {@link
+ * #onSave(CommitBuilder)}).
+ */
+@VisibleForTesting
+public class CheckersByRepositoryNotes extends VersionedMetaData {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private static final int MAX_NOTE_SZ = 1 << 19;
+
+  public static CheckersByRepositoryNotes load(
+      AllProjectsName allProjectsName, Repository allProjectsRepo)
+      throws IOException, ConfigInvalidException {
+    return new CheckersByRepositoryNotes(allProjectsName, allProjectsRepo).load();
+  }
+
+  public static CheckersByRepositoryNotes load(
+      AllProjectsName allProjectsName, Repository allProjectsRepo, @Nullable ObjectId rev)
+      throws IOException, ConfigInvalidException {
+    return new CheckersByRepositoryNotes(allProjectsName, allProjectsRepo).load(rev);
+  }
+
+  private final AllProjectsName allProjectsName;
+  private final Repository repo;
+
+  // the loaded note map
+  private NoteMap noteMap;
+
+  // Staged note map updates that should be executed on save.
+  private List<NoteMapUpdate> noteMapUpdates = new ArrayList<>();
+
+  private CheckersByRepositoryNotes(AllProjectsName allProjectsName, Repository allProjectsRepo) {
+    this.allProjectsName = requireNonNull(allProjectsName, "allProjectsName");
+    this.repo = requireNonNull(allProjectsRepo, "allProjectsRepo");
+  }
+
+  public Repository getRepository() {
+    return repo;
+  }
+
+  @Override
+  protected String getRefName() {
+    return CheckerRef.REFS_META_CHECKERS;
+  }
+
+  /**
+   * Loads the checkers by repository notes from the current tip of the {@code refs/meta/checkers}
+   * branch.
+   *
+   * @return {@link CheckersByRepositoryNotes} instance for chaining
+   */
+  private CheckersByRepositoryNotes load() throws IOException, ConfigInvalidException {
+    super.load(allProjectsName, repo);
+    return this;
+  }
+
+  /**
+   * Loads the checkers by repository notes from the specified revision of the {@code
+   * refs/meta/checkers} branch.
+   *
+   * @param rev the revision from which the checkers by repository notes should be loaded, if {@code
+   *     null} the checkers by repository notes are loaded from the current tip, if {@link
+   *     ObjectId#zeroId()} it's assumed that the {@code refs/meta/checkers} branch doesn't exist
+   *     and the loaded checkers by repository will be empty
+   * @return {@link CheckersByRepositoryNotes} instance for chaining
+   */
+  CheckersByRepositoryNotes load(@Nullable ObjectId rev)
+      throws IOException, ConfigInvalidException {
+    if (rev == null) {
+      return load();
+    }
+    if (ObjectId.zeroId().equals(rev)) {
+      load(allProjectsName, repo, null);
+      return this;
+    }
+    load(allProjectsName, repo, rev);
+    return this;
+  }
+
+  /**
+   * Parses and returns the set of checker UUIDs for the specified repository.
+   *
+   * <p>Invalid checker UUIDs are silently ignored.
+   *
+   * @param repositoryName the name of the repository for which the set of checker UUIDs should be
+   *     parsed and returned
+   * @return the set of checker UUIDs for the specified repository, empty set if no checkers apply
+   *     for this repository
+   * @throws IOException if reading the note with the checker UUID list fails
+   */
+  public ImmutableSortedSet<String> get(Project.NameKey repositoryName) throws IOException {
+    checkLoaded();
+    ObjectId noteId = computeRepositorySha1(repositoryName);
+    if (!noteMap.contains(noteId)) {
+      return ImmutableSortedSet.of();
+    }
+
+    try (RevWalk rw = new RevWalk(repo)) {
+      ObjectId noteDataId = noteMap.get(noteId);
+      byte[] raw = readNoteData(rw, noteDataId);
+      return parseCheckerUuidsFromNote(noteId, raw, noteDataId);
+    }
+  }
+
+  /**
+   * Inserts a new checker for a repository.
+   *
+   * <p><strong>Note:</strong> This method doesn't perform the update. It only contains the
+   * instructions for the update. To apply the update for real and write the result back to NoteDb,
+   * call {@link #commit(MetaDataUpdate)} on this {@code CheckersByRepositoryNotes}.
+   *
+   * @param checkerUuid the UUID of the checker that should be inserted for the given repository
+   * @param repositoryName the name of the repository for which the checker should be inserted
+   */
+  public void insert(String checkerUuid, Project.NameKey repositoryName) {
+    checkLoaded();
+
+    noteMapUpdates.add(
+        (rw, n, f) -> {
+          insert(rw, inserter, n, f, checkerUuid, repositoryName);
+        });
+  }
+
+  /**
+   * Removes a checker from a repository.
+   *
+   * <p><strong>Note:</strong> This method doesn't perform the update. It only contains the
+   * instructions for the update. To apply the update for real and write the result back to NoteDb,
+   * call {@link #commit(MetaDataUpdate)} on this {@code CheckersByRepositoryNotes}.
+   *
+   * @param checkerUuid the UUID of the checker that should be removed from the given repository
+   * @param repositoryName the name of the repository for which the checker should be removed
+   */
+  public void remove(String checkerUuid, Project.NameKey repositoryName) {
+    checkLoaded();
+
+    noteMapUpdates.add(
+        (rw, n, f) -> {
+          remove(rw, inserter, n, f, checkerUuid, repositoryName);
+        });
+  }
+
+  /**
+   * Updates the repository for a checker.
+   *
+   * <p><strong>Note:</strong> This method doesn't perform the update. It only contains the
+   * instructions for the update. To apply the update for real and write the result back to NoteDb,
+   * call {@link #commit(MetaDataUpdate)} on this {@code CheckersByRepositoryNotes}.
+   *
+   * @param checkerUuid the UUID of the checker that should be removed from the given repository
+   * @param oldRepositoryName the name of the repository for which the checker should be removed
+   * @param newRepositoryName the name of the repository for which the checker should be inserted
+   */
+  public void update(
+      String checkerUuid, Project.NameKey oldRepositoryName, Project.NameKey newRepositoryName) {
+    checkLoaded();
+
+    if (oldRepositoryName.equals(newRepositoryName)) {
+      return;
+    }
+
+    noteMapUpdates.add(
+        (rw, n, f) -> {
+          remove(rw, inserter, n, f, checkerUuid, oldRepositoryName);
+          insert(rw, inserter, n, f, checkerUuid, newRepositoryName);
+        });
+  }
+
+  @Override
+  protected void onLoad() throws IOException, ConfigInvalidException {
+    logger.atFine().log("Reading checkers by repository note map");
+
+    noteMap = revision != null ? NoteMap.read(reader, revision) : NoteMap.newEmptyMap();
+  }
+
+  private void checkLoaded() {
+    checkState(noteMap != null, "Checkers by repository not loaded yet");
+  }
+
+  @Override
+  protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
+    if (noteMapUpdates.isEmpty()) {
+      return false;
+    }
+
+    logger.atFine().log("Updating checkers by repository");
+
+    if (Strings.isNullOrEmpty(commit.getMessage())) {
+      commit.setMessage("Update checkers by repository\n");
+    }
+
+    try (RevWalk rw = new RevWalk(reader)) {
+      ImmutableSortedSet.Builder<String> footersBuilder = ImmutableSortedSet.naturalOrder();
+      for (NoteMapUpdate noteMapUpdate : noteMapUpdates) {
+        noteMapUpdate.execute(rw, noteMap, footersBuilder);
+      }
+      noteMapUpdates.clear();
+      ImmutableSortedSet<String> footers = footersBuilder.build();
+      if (!footers.isEmpty()) {
+        commit.setMessage(
+            footers.stream().collect(joining("\n", commit.getMessage().trim() + "\n\n", "")));
+      }
+
+      RevTree oldTree = revision != null ? rw.parseTree(revision) : null;
+      ObjectId newTreeId = noteMap.writeTree(inserter);
+      if (newTreeId.equals(oldTree)) {
+        return false;
+      }
+
+      commit.setTreeId(newTreeId);
+      return true;
+    }
+  }
+
+  private static byte[] readNoteData(RevWalk rw, ObjectId noteDataId) throws IOException {
+    return rw.getObjectReader().open(noteDataId, OBJ_BLOB).getCachedBytes(MAX_NOTE_SZ);
+  }
+
+  /**
+   * Parses a list of checker UUIDs from a byte array that contain the checker UUIDs as a plain text
+   * with one checker UUID per line:
+   *
+   * <pre>
+   * e021147da7713263c46d3126b77a863930ff555b
+   * e497b37e55074b7a11832a7e2d18c44b4dab8017
+   * 8cc1d2415fd4fc78b7d5cc02ac59ee3939d0e1da
+   * </pre>
+   *
+   * <p>Invalid checker UUIDs are silently ignored.
+   */
+  private static ImmutableSortedSet<String> parseCheckerUuidsFromNote(
+      ObjectId noteId, byte[] raw, ObjectId blobId) {
+    ImmutableSortedSet<String> lines = parseNote(raw);
+    ImmutableSortedSet.Builder<String> checkerUuids = ImmutableSortedSet.naturalOrder();
+    lines.forEach(
+        line -> {
+          if (CheckerUuid.isUuid(line)) {
+            checkerUuids.add(line);
+          } else {
+            logger.atWarning().log(
+                "Ignoring invalid checker UUID %s in note %s with blob ID %s.",
+                line, noteId.name(), blobId.name());
+          }
+        });
+    return checkerUuids.build();
+  }
+
+  /**
+   * Parses all entries from a note, one entry per line.
+   *
+   * <p>Doesn't validate the entries are valid checker UUIDs.
+   */
+  private static ImmutableSortedSet<String> parseNote(byte[] raw) {
+    return Splitter.on('\n')
+        .splitToList(new String(raw, UTF_8))
+        .stream()
+        .collect(toImmutableSortedSet(naturalOrder()));
+  }
+
+  /**
+   * Insert a checker UUID for a repository and updates the note map.
+   *
+   * <p>No-op if the checker UUID is already recorded for the repository.
+   */
+  private static void insert(
+      RevWalk rw,
+      ObjectInserter ins,
+      NoteMap noteMap,
+      ImmutableSortedSet.Builder<String> footers,
+      String checkerUuid,
+      Project.NameKey repositoryName)
+      throws IOException {
+    ObjectId noteId = computeRepositorySha1(repositoryName);
+    ImmutableSortedSet.Builder<String> newLinesBuilder = ImmutableSortedSet.naturalOrder();
+    if (noteMap.contains(noteId)) {
+      ObjectId noteDataId = noteMap.get(noteId);
+      byte[] raw = readNoteData(rw, noteDataId);
+      ImmutableSortedSet<String> oldLines = parseNote(raw);
+      if (oldLines.contains(checkerUuid)) {
+        return;
+      }
+      newLinesBuilder.addAll(oldLines);
+    }
+
+    newLinesBuilder.add(checkerUuid);
+    byte[] raw = Joiner.on("\n").join(newLinesBuilder.build()).getBytes(UTF_8);
+    ObjectId noteData = ins.insert(OBJ_BLOB, raw);
+    noteMap.set(noteId, noteData);
+    addFooters(footers, checkerUuid, repositoryName);
+  }
+
+  /**
+   * Removes a checker UUID from a repository and updates the note map.
+   *
+   * <p>No-op if the checker UUID is already not recorded for the repository.
+   */
+  private static void remove(
+      RevWalk rw,
+      ObjectInserter ins,
+      NoteMap noteMap,
+      ImmutableSortedSet.Builder<String> footers,
+      String checkerUuid,
+      Project.NameKey repositoryName)
+      throws IOException {
+    ObjectId noteId = computeRepositorySha1(repositoryName);
+    ImmutableSortedSet.Builder<String> newLinesBuilder = ImmutableSortedSet.naturalOrder();
+    if (noteMap.contains(noteId)) {
+      ObjectId noteDataId = noteMap.get(noteId);
+      byte[] raw = readNoteData(rw, noteDataId);
+      ImmutableSortedSet<String> oldLines = parseNote(raw);
+      if (!oldLines.contains(checkerUuid)) {
+        return;
+      }
+      oldLines.stream().filter(line -> !line.equals(checkerUuid)).forEach(newLinesBuilder::add);
+    }
+
+    ImmutableSortedSet<String> newLines = newLinesBuilder.build();
+    if (newLines.isEmpty()) {
+      noteMap.remove(noteId);
+      return;
+    }
+
+    byte[] raw = Joiner.on("\n").join(newLines).getBytes(UTF_8);
+    ObjectId noteData = ins.insert(OBJ_BLOB, raw);
+    noteMap.set(noteId, noteData);
+    addFooters(footers, checkerUuid, repositoryName);
+  }
+
+  private static void addFooters(
+      ImmutableSortedSet.Builder<String> footers,
+      String checkerUuid,
+      Project.NameKey repositoryName) {
+    footers.add("Repository: " + repositoryName.get());
+    footers.add("Checker: " + checkerUuid);
+  }
+
+  /**
+   * Returns the SHA1 of the repository that is used as note ID in the {@code refs/meta/checkers}
+   * notes branch.
+   *
+   * @param repositoryName the name of the repository for which the SHA1 should be computed and
+   *     returned
+   * @return SHA1 for the given repository name
+   */
+  @VisibleForTesting
+  @SuppressWarnings("deprecation") // Use Hashing.sha1 for compatibility.
+  public static ObjectId computeRepositorySha1(Project.NameKey repositoryName) {
+    return ObjectId.fromRaw(Hashing.sha1().hashString(repositoryName.get(), UTF_8).asBytes());
+  }
+
+  @FunctionalInterface
+  private interface NoteMapUpdate {
+    void execute(RevWalk rw, NoteMap noteMap, ImmutableSortedSet.Builder<String> footers)
+        throws IOException;
+  }
+}
diff --git a/java/com/google/gerrit/plugins/checks/db/NoteDbCheckers.java b/java/com/google/gerrit/plugins/checks/db/NoteDbCheckers.java
new file mode 100644
index 0000000..ddb5025
--- /dev/null
+++ b/java/com/google/gerrit/plugins/checks/db/NoteDbCheckers.java
@@ -0,0 +1,116 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.checks.db;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Streams;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.plugins.checks.Checker;
+import com.google.gerrit.plugins.checks.CheckerRef;
+import com.google.gerrit.plugins.checks.CheckerUuid;
+import com.google.gerrit.plugins.checks.Checkers;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.List;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+
+/** Class to read checkers from NoteDb. */
+@Singleton
+class NoteDbCheckers implements Checkers {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  private final GitRepositoryManager repoManager;
+  private final AllProjectsName allProjectsName;
+
+  @Inject
+  NoteDbCheckers(GitRepositoryManager repoManager, AllProjectsName allProjectsName) {
+    this.repoManager = repoManager;
+    this.allProjectsName = allProjectsName;
+  }
+
+  @Override
+  public Optional<Checker> getChecker(String checkerUuid)
+      throws IOException, ConfigInvalidException {
+    if (!CheckerUuid.isUuid(checkerUuid)) {
+      return Optional.empty();
+    }
+
+    try (Repository allProjectsRepo = repoManager.openRepository(allProjectsName)) {
+      CheckerConfig checkerConfig =
+          CheckerConfig.loadForChecker(allProjectsName, allProjectsRepo, checkerUuid);
+      return checkerConfig.getLoadedChecker();
+    }
+  }
+
+  @Override
+  public ImmutableList<Checker> listCheckers() throws IOException {
+    try (Repository allProjectsRepo = repoManager.openRepository(allProjectsName)) {
+      List<Ref> checkerRefs =
+          allProjectsRepo.getRefDatabase().getRefsByPrefix(CheckerRef.REFS_CHECKERS);
+      ImmutableList<String> sortedCheckerUuids =
+          checkerRefs
+              .stream()
+              .map(CheckerUuid::fromRef)
+              .flatMap(Streams::stream)
+              .sorted()
+              .collect(toImmutableList());
+      ImmutableList.Builder<Checker> sortedCheckers = ImmutableList.builder();
+      for (String checkerUuid : sortedCheckerUuids) {
+        try {
+          CheckerConfig checkerConfig =
+              CheckerConfig.loadForChecker(allProjectsName, allProjectsRepo, checkerUuid);
+          checkerConfig.getLoadedChecker().ifPresent(sortedCheckers::add);
+        } catch (ConfigInvalidException e) {
+          logger.atWarning().withCause(e).log(
+              "Ignore invalid checker %s on listing checkers", checkerUuid);
+        }
+      }
+      return sortedCheckers.build();
+    }
+  }
+
+  @Override
+  public ImmutableSet<Checker> checkersOf(Project.NameKey repositoryName)
+      throws IOException, ConfigInvalidException {
+    try (Repository allProjectsRepo = repoManager.openRepository(allProjectsName)) {
+      ImmutableSet<String> checkerUuids =
+          CheckersByRepositoryNotes.load(allProjectsName, allProjectsRepo).get(repositoryName);
+
+      ImmutableSet.Builder<Checker> checkers = ImmutableSet.builder();
+      for (String checkerUuid : checkerUuids) {
+        try {
+          CheckerConfig checkerConfig =
+              CheckerConfig.loadForChecker(allProjectsName, allProjectsRepo, checkerUuid);
+          checkerConfig.getLoadedChecker().ifPresent(checkers::add);
+        } catch (ConfigInvalidException e) {
+          logger.atWarning().withCause(e).log(
+              "Ignore invalid checker %s on listing checkers for repository %s",
+              checkerUuid, repositoryName);
+        }
+      }
+      return checkers.build();
+    }
+  }
+}
diff --git a/java/com/google/gerrit/plugins/checks/db/NoteDbCheckersModule.java b/java/com/google/gerrit/plugins/checks/db/NoteDbCheckersModule.java
new file mode 100644
index 0000000..0d2fd17
--- /dev/null
+++ b/java/com/google/gerrit/plugins/checks/db/NoteDbCheckersModule.java
@@ -0,0 +1,46 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.checks.db;
+
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.plugins.checks.Checkers;
+import com.google.gerrit.plugins.checks.CheckersUpdate;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.ServerInitiated;
+import com.google.gerrit.server.UserInitiated;
+import com.google.inject.Provides;
+
+/** Bind NoteDb implementation for checker storage layer. */
+public class NoteDbCheckersModule extends FactoryModule {
+  @Override
+  protected void configure() {
+    bind(Checkers.class).to(NoteDbCheckers.class);
+    factory(NoteDbCheckersUpdate.Factory.class);
+  }
+
+  @Provides
+  @ServerInitiated
+  CheckersUpdate provideServerInitiatedCheckersUpdate(
+      NoteDbCheckersUpdate.Factory checkersUpdateFactory) {
+    return checkersUpdateFactory.createWithServerIdent();
+  }
+
+  @Provides
+  @UserInitiated
+  CheckersUpdate provideUserInitiatedCheckersUpdate(
+      NoteDbCheckersUpdate.Factory checkersUpdateFactory, IdentifiedUser currentUser) {
+    return checkersUpdateFactory.create(currentUser);
+  }
+}
diff --git a/java/com/google/gerrit/plugins/checks/db/NoteDbCheckersUpdate.java b/java/com/google/gerrit/plugins/checks/db/NoteDbCheckersUpdate.java
new file mode 100644
index 0000000..371cfbc
--- /dev/null
+++ b/java/com/google/gerrit/plugins/checks/db/NoteDbCheckersUpdate.java
@@ -0,0 +1,294 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.checks.db;
+
+import com.google.common.base.Throwables;
+import com.google.gerrit.git.LockFailureException;
+import com.google.gerrit.git.RefUpdateUtil;
+import com.google.gerrit.plugins.checks.Checker;
+import com.google.gerrit.plugins.checks.CheckerCreation;
+import com.google.gerrit.plugins.checks.CheckerUpdate;
+import com.google.gerrit.plugins.checks.CheckersUpdate;
+import com.google.gerrit.plugins.checks.NoSuchCheckerException;
+import com.google.gerrit.plugins.checks.api.CheckerStatus;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryHelper.ActionType;
+import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.gwtorm.server.OrmDuplicateKeyException;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+
+/** Class to write checkers to NoteDb. */
+class NoteDbCheckersUpdate implements CheckersUpdate {
+  interface Factory {
+    /**
+     * Creates a {@code CheckersUpdate} which uses the identity of the specified user to mark
+     * database modifications executed by it. For NoteDb, this identity is used as author and
+     * committer for all related commits.
+     *
+     * <p><strong>Note</strong>: Please use this method with care and consider using the {@link
+     * com.google.gerrit.server.UserInitiated} annotation on the provider of a {@code
+     * CheckersUpdate} instead.
+     *
+     * @param currentUser the user to which modifications should be attributed
+     */
+    NoteDbCheckersUpdate create(IdentifiedUser currentUser);
+
+    /**
+     * Creates a {@code CheckersUpdate} which uses the server identity to mark database
+     * modifications executed by it. For NoteDb, this identity is used as author and committer for
+     * all related commits.
+     *
+     * <p><strong>Note</strong>: Please use this method with care and consider using the {@link
+     * com.google.gerrit.server.ServerInitiated} annotation on the provider of a {@code
+     * CheckersUpdate} instead.
+     */
+    NoteDbCheckersUpdate createWithServerIdent();
+  }
+
+  private final GitRepositoryManager repoManager;
+  private final AllProjectsName allProjectsName;
+  private final MetaDataUpdateFactory metaDataUpdateFactory;
+  private final GitReferenceUpdated gitRefUpdated;
+  private final RetryHelper retryHelper;
+  private final Optional<IdentifiedUser> currentUser;
+
+  @AssistedInject
+  NoteDbCheckersUpdate(
+      GitRepositoryManager repoManager,
+      AllProjectsName allProjectsName,
+      MetaDataUpdate.InternalFactory metaDataUpdateInternalFactory,
+      GitReferenceUpdated gitRefUpdated,
+      RetryHelper retryHelper,
+      @GerritPersonIdent PersonIdent serverIdent) {
+    this(
+        repoManager,
+        allProjectsName,
+        metaDataUpdateInternalFactory,
+        gitRefUpdated,
+        retryHelper,
+        serverIdent,
+        Optional.empty());
+  }
+
+  @AssistedInject
+  NoteDbCheckersUpdate(
+      GitRepositoryManager repoManager,
+      AllProjectsName allProjectsName,
+      MetaDataUpdate.InternalFactory metaDataUpdateInternalFactory,
+      GitReferenceUpdated gitRefUpdated,
+      RetryHelper retryHelper,
+      @GerritPersonIdent PersonIdent serverIdent,
+      @Assisted IdentifiedUser currentUser) {
+    this(
+        repoManager,
+        allProjectsName,
+        metaDataUpdateInternalFactory,
+        gitRefUpdated,
+        retryHelper,
+        serverIdent,
+        Optional.of(currentUser));
+  }
+
+  private NoteDbCheckersUpdate(
+      GitRepositoryManager repoManager,
+      AllProjectsName allProjectsName,
+      MetaDataUpdate.InternalFactory metaDataUpdateInternalFactory,
+      GitReferenceUpdated gitRefUpdated,
+      RetryHelper retryHelper,
+      @GerritPersonIdent PersonIdent serverIdent,
+      Optional<IdentifiedUser> currentUser) {
+    this.repoManager = repoManager;
+    this.allProjectsName = allProjectsName;
+    this.gitRefUpdated = gitRefUpdated;
+    this.retryHelper = retryHelper;
+    this.currentUser = currentUser;
+
+    metaDataUpdateFactory =
+        getMetaDataUpdateFactory(metaDataUpdateInternalFactory, currentUser, serverIdent);
+  }
+
+  @Override
+  public Checker createChecker(CheckerCreation checkerCreation, CheckerUpdate checkerUpdate)
+      throws OrmDuplicateKeyException, IOException, ConfigInvalidException {
+    try {
+      return retryHelper.execute(
+          RetryHelper.ActionType.PLUGIN_UPDATE,
+          () -> createCheckerInNoteDb(checkerCreation, checkerUpdate),
+          LockFailureException.class::isInstance);
+    } catch (Exception e) {
+      Throwables.throwIfUnchecked(e);
+      Throwables.throwIfInstanceOf(e, OrmDuplicateKeyException.class);
+      Throwables.throwIfInstanceOf(e, IOException.class);
+      Throwables.throwIfInstanceOf(e, ConfigInvalidException.class);
+      throw new IOException(e);
+    }
+  }
+
+  private Checker createCheckerInNoteDb(
+      CheckerCreation checkerCreation, CheckerUpdate checkerUpdate)
+      throws OrmDuplicateKeyException, IOException, ConfigInvalidException {
+    try (Repository allProjectsRepo = repoManager.openRepository(allProjectsName)) {
+      CheckerConfig checkerConfig =
+          CheckerConfig.createForNewChecker(allProjectsName, allProjectsRepo, checkerCreation);
+      checkerConfig.setCheckerUpdate(checkerUpdate);
+
+      CheckersByRepositoryNotes checkersByRepositoryNotes =
+          CheckersByRepositoryNotes.load(allProjectsName, allProjectsRepo);
+      checkersByRepositoryNotes.insert(
+          checkerCreation.getCheckerUuid(), checkerCreation.getRepository());
+
+      commit(allProjectsRepo, checkerConfig, checkersByRepositoryNotes);
+
+      return checkerConfig
+          .getLoadedChecker()
+          .orElseThrow(
+              () -> new IllegalStateException("Created checker wasn't automatically loaded"));
+    }
+  }
+
+  private void commit(
+      Repository allProjectsRepo,
+      CheckerConfig checkerConfig,
+      CheckersByRepositoryNotes checkersByRepositoryNotes)
+      throws IOException {
+    BatchRefUpdate batchRefUpdate = allProjectsRepo.getRefDatabase().newBatchUpdate();
+    try (MetaDataUpdate metaDataUpdate =
+        metaDataUpdateFactory.create(allProjectsName, allProjectsRepo, batchRefUpdate)) {
+      checkerConfig.commit(metaDataUpdate);
+      checkersByRepositoryNotes.commit(metaDataUpdate);
+    }
+    RefUpdateUtil.executeChecked(batchRefUpdate, allProjectsRepo);
+
+    gitRefUpdated.fire(
+        allProjectsName, batchRefUpdate, currentUser.map(user -> user.state()).orElse(null));
+  }
+
+  private static MetaDataUpdateFactory getMetaDataUpdateFactory(
+      MetaDataUpdate.InternalFactory metaDataUpdateInternalFactory,
+      Optional<IdentifiedUser> currentUser,
+      PersonIdent serverIdent) {
+    return (projectName, repository, batchRefUpdate) -> {
+      MetaDataUpdate metaDataUpdate =
+          metaDataUpdateInternalFactory.create(projectName, repository, batchRefUpdate);
+      metaDataUpdate.getCommitBuilder().setCommitter(serverIdent);
+      PersonIdent authorIdent;
+      if (currentUser.isPresent()) {
+        metaDataUpdate.setAuthor(currentUser.get());
+        authorIdent =
+            currentUser.get().newCommitterIdent(serverIdent.getWhen(), serverIdent.getTimeZone());
+      } else {
+        authorIdent = serverIdent;
+      }
+      metaDataUpdate.getCommitBuilder().setAuthor(authorIdent);
+      return metaDataUpdate;
+    };
+  }
+
+  @FunctionalInterface
+  private interface MetaDataUpdateFactory {
+    MetaDataUpdate create(
+        Project.NameKey projectName, Repository repository, BatchRefUpdate batchRefUpdate)
+        throws IOException;
+  }
+
+  @Override
+  public Checker updateChecker(String checkerUuid, CheckerUpdate checkerUpdate)
+      throws NoSuchCheckerException, IOException, ConfigInvalidException {
+    Optional<Timestamp> updatedOn = checkerUpdate.getUpdatedOn();
+    if (!updatedOn.isPresent()) {
+      updatedOn = Optional.of(TimeUtil.nowTs());
+      checkerUpdate = checkerUpdate.toBuilder().setUpdatedOn(updatedOn.get()).build();
+    }
+    return updateCheckerWithRetry(checkerUuid, checkerUpdate);
+  }
+
+  private Checker updateCheckerWithRetry(String checkerUuid, CheckerUpdate checkerUpdate)
+      throws NoSuchCheckerException, IOException, ConfigInvalidException {
+    try {
+      return retryHelper.execute(
+          ActionType.PLUGIN_UPDATE,
+          () -> updateCheckerInNoteDb(checkerUuid, checkerUpdate),
+          LockFailureException.class::isInstance);
+    } catch (Exception e) {
+      Throwables.throwIfUnchecked(e);
+      Throwables.throwIfInstanceOf(e, IOException.class);
+      Throwables.throwIfInstanceOf(e, ConfigInvalidException.class);
+      Throwables.throwIfInstanceOf(e, NoSuchCheckerException.class);
+      throw new IOException(e);
+    }
+  }
+
+  private Checker updateCheckerInNoteDb(String checkerUuid, CheckerUpdate checkerUpdate)
+      throws IOException, ConfigInvalidException, NoSuchCheckerException {
+    try (Repository allProjectsRepo = repoManager.openRepository(allProjectsName)) {
+      CheckerConfig checkerConfig =
+          CheckerConfig.loadForChecker(allProjectsName, allProjectsRepo, checkerUuid);
+      checkerConfig.setCheckerUpdate(checkerUpdate);
+      if (!checkerConfig.getLoadedChecker().isPresent()) {
+        throw new NoSuchCheckerException(checkerUuid);
+      }
+
+      CheckersByRepositoryNotes checkersByRepositoryNotes =
+          CheckersByRepositoryNotes.load(allProjectsName, allProjectsRepo);
+
+      Checker checker = checkerConfig.getLoadedChecker().get();
+      Project.NameKey oldRepositoryName = checker.getRepository();
+      Project.NameKey newRepositoryName = checkerUpdate.getRepository().orElse(oldRepositoryName);
+
+      CheckerStatus newStatus = checkerUpdate.getStatus().orElse(checker.getStatus());
+      switch (newStatus) {
+          // May produce some redundant notes updates, but CheckersByRepositoryNotes knows how to
+          // short-circuit on no-ops, and the logic in this method is simple.
+        case DISABLED:
+          checkersByRepositoryNotes.remove(checkerUuid, oldRepositoryName);
+          checkersByRepositoryNotes.remove(checkerUuid, newRepositoryName);
+          break;
+        case ENABLED:
+          if (oldRepositoryName.equals(newRepositoryName)) {
+            checkersByRepositoryNotes.insert(checkerUuid, newRepositoryName);
+          } else {
+            checkersByRepositoryNotes.update(checkerUuid, oldRepositoryName, newRepositoryName);
+          }
+          break;
+        default:
+          throw new IllegalStateException("invalid checker status: " + newStatus);
+      }
+
+      commit(allProjectsRepo, checkerConfig, checkersByRepositoryNotes);
+
+      Checker updatedChecker =
+          checkerConfig
+              .getLoadedChecker()
+              .orElseThrow(
+                  () -> new IllegalStateException("Updated checker wasn't automatically loaded"));
+      return updatedChecker;
+    }
+  }
+}
diff --git a/java/com/google/gerrit/plugins/checks/testing/BUILD b/java/com/google/gerrit/plugins/checks/testing/BUILD
new file mode 100644
index 0000000..f50b6a0
--- /dev/null
+++ b/java/com/google/gerrit/plugins/checks/testing/BUILD
@@ -0,0 +1,18 @@
+package(
+    default_testonly = True,
+    default_visibility = ["//plugins/checks:visibility"],
+)
+
+java_library(
+    name = "testing",
+    srcs = glob(["*.java"]),
+    deps = [
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server/testing",
+        "//java/com/google/gerrit/truth",
+        "//lib/truth",
+        "//lib/truth:truth-java8-extension",
+        "//plugins:plugin-api",
+        "//plugins/checks:checks__plugin",
+    ],
+)
diff --git a/java/com/google/gerrit/plugins/checks/testing/CheckerConfigSubject.java b/java/com/google/gerrit/plugins/checks/testing/CheckerConfigSubject.java
new file mode 100644
index 0000000..250036f
--- /dev/null
+++ b/java/com/google/gerrit/plugins/checks/testing/CheckerConfigSubject.java
@@ -0,0 +1,90 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.checks.testing;
+
+import static com.google.common.truth.Truth.assertAbout;
+
+import com.google.common.truth.ComparableSubject;
+import com.google.common.truth.FailureMetadata;
+import com.google.common.truth.IterableSubject;
+import com.google.common.truth.StringSubject;
+import com.google.common.truth.Subject;
+import com.google.common.truth.Truth;
+import com.google.common.truth.Truth8;
+import com.google.gerrit.plugins.checks.Checker;
+import com.google.gerrit.plugins.checks.api.CheckerStatus;
+import com.google.gerrit.plugins.checks.db.CheckerConfig;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.testing.ObjectIdSubject;
+import com.google.gerrit.truth.OptionalSubject;
+import java.sql.Timestamp;
+import java.util.Optional;
+
+public class CheckerConfigSubject extends Subject<CheckerConfigSubject, CheckerConfig> {
+  public static CheckerConfigSubject assertThat(CheckerConfig checkerConfig) {
+    return assertAbout(CheckerConfigSubject::new).that(checkerConfig);
+  }
+
+  private CheckerConfigSubject(FailureMetadata metadata, CheckerConfig actual) {
+    super(metadata, actual);
+  }
+
+  public void hasUuid(String expectedUuid) {
+    Truth.assertThat(checker().getUuid()).named("uuid").isEqualTo(expectedUuid);
+  }
+
+  public void hasName(String expectedName) {
+    Truth.assertThat(checker().getName()).named("name").isEqualTo(expectedName);
+  }
+
+  public OptionalSubject<StringSubject, String> hasDescriptionThat() {
+    return OptionalSubject.assertThat(checker().getDescription(), Truth::assertThat)
+        .named("description");
+  }
+
+  public OptionalSubject<StringSubject, String> hasUrlThat() {
+    return OptionalSubject.assertThat(checker().getUrl(), Truth::assertThat).named("url");
+  }
+
+  public void hasRepository(Project.NameKey expectedRepository) {
+    Truth.assertThat(checker().getRepository()).named("repository").isEqualTo(expectedRepository);
+  }
+
+  public void hasStatus(CheckerStatus expectedStatus) {
+    Truth.assertThat(checker().getStatus()).named("status").isEqualTo(expectedStatus);
+  }
+
+  public ComparableSubject<?, Timestamp> hasCreatedOnThat() {
+    return Truth.assertThat(checker().getCreatedOn()).named("createdOn");
+  }
+
+  public ObjectIdSubject hasRefStateThat() {
+    return ObjectIdSubject.assertThat(checker().getRefState()).named("refState");
+  }
+
+  public IterableSubject configStringList(String name) {
+    isNotNull();
+    return Truth.assertThat(actual().getConfigForTesting().getStringList("checker", null, name))
+        .asList()
+        .named("value of checker.%s", name);
+  }
+
+  private Checker checker() {
+    isNotNull();
+    Optional<Checker> checker = actual().getLoadedChecker();
+    Truth8.assertThat(checker).named("checker is loaded").isPresent();
+    return checker.get();
+  }
+}
diff --git a/javatests/com/google/gerrit/plugins/checks/BUILD b/javatests/com/google/gerrit/plugins/checks/BUILD
new file mode 100644
index 0000000..b659377
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/checks/BUILD
@@ -0,0 +1,19 @@
+package(default_visibility = ["//plugins/checks:visibility"])
+
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+junit_tests(
+    name = "checks_tests",
+    srcs = glob(["*.java"]),
+    deps = [
+        "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//lib:guava",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/truth",
+        "//lib/truth:truth-java8-extension",
+        "//plugins/checks:checks__plugin",
+    ],
+)
diff --git a/javatests/com/google/gerrit/plugins/checks/CheckerUrlTest.java b/javatests/com/google/gerrit/plugins/checks/CheckerUrlTest.java
new file mode 100644
index 0000000..cdefe42
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/checks/CheckerUrlTest.java
@@ -0,0 +1,70 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.checks;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assert_;
+
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.testing.GerritBaseTests;
+import org.junit.Test;
+
+public class CheckerUrlTest extends GerritBaseTests {
+  @Test
+  public void validUrls() throws Exception {
+    assertThat(CheckerUrl.clean("https://foo.com/")).isEqualTo("https://foo.com/");
+    assertThat(CheckerUrl.clean("http://foo.com/")).isEqualTo("http://foo.com/");
+  }
+
+  @Test
+  public void emptyUrls() throws Exception {
+    assertThat(CheckerUrl.clean("")).isEqualTo("");
+    assertThat(CheckerUrl.clean(" ")).isEqualTo("");
+    assertThat(CheckerUrl.clean(" \t ")).isEqualTo("");
+  }
+
+  @Test
+  public void trimUrls() throws Exception {
+    assertThat(CheckerUrl.clean(" https://foo.com/")).isEqualTo("https://foo.com/");
+    assertThat(CheckerUrl.clean("https://foo.com/ ")).isEqualTo("https://foo.com/");
+    assertThat(CheckerUrl.clean(" https://foo.com/ ")).isEqualTo("https://foo.com/");
+  }
+
+  @Test
+  public void notUrls() throws Exception {
+    assertInvalidUrl("foobar", "invalid URL: foobar");
+    assertInvalidUrl("foobar:", "invalid URL: foobar:");
+    assertInvalidUrl("foo http://bar.com/", "invalid URL: foo http://bar.com/");
+  }
+
+  @Test
+  public void nonHttpUrls() throws Exception {
+    assertInvalidUrl("ftp://foo.com/", "only http/https URLs supported: ftp://foo.com/");
+    assertInvalidUrl(
+        "mailto:user@example.com", "only http/https URLs supported: mailto:user@example.com");
+    assertInvalidUrl(
+        "javascript:alert('h4x0r3d')",
+        "only http/https URLs supported: javascript:alert('h4x0r3d')");
+  }
+
+  private static void assertInvalidUrl(String url, String expectedMessage) {
+    try {
+      CheckerUrl.clean(url);
+      assert_().fail("expected BadRequestException");
+    } catch (BadRequestException e) {
+      assertThat(e).hasMessageThat().isEqualTo(expectedMessage);
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/plugins/checks/CheckerUuidTest.java b/javatests/com/google/gerrit/plugins/checks/CheckerUuidTest.java
new file mode 100644
index 0000000..ebea465
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/checks/CheckerUuidTest.java
@@ -0,0 +1,109 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.checks;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assert_;
+import static com.google.common.truth.Truth8.assertThat;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.testing.GerritBaseTests;
+import org.eclipse.jgit.lib.Ref;
+import org.junit.Test;
+
+public class CheckerUuidTest extends GerritBaseTests {
+  private static final ImmutableSet<String> INVALID_CHECKER_UUIDS =
+      ImmutableSet.of(
+          "",
+          "437ee3",
+          "Id852b02b44d3148de21603fecbc817d03d6899fe",
+          "foo",
+          "437ee373885fbc47b103dc722800448320e8bc61-foo",
+          "437ee373885fbc47b103dc722800448320e8bc61 foo");
+
+  @Test
+  public void createdUuidsForSameInputShouldBeDifferent() {
+    String checkerName = "my-checker";
+    String uuid1 = CheckerUuid.make(checkerName);
+    String uuid2 = CheckerUuid.make(checkerName);
+    assertThat(uuid2).isNotEqualTo(uuid1);
+  }
+
+  @Test
+  public void isUuid() {
+    // valid UUIDs
+    assertThat(CheckerUuid.isUuid("437ee373885fbc47b103dc722800448320e8bc61")).isTrue();
+    assertThat(CheckerUuid.isUuid(CheckerUuid.make("my-checker"))).isTrue();
+
+    // invalid UUIDs
+    assertThat(CheckerUuid.isUuid(null)).isFalse();
+    for (String invalidCheckerUuid : INVALID_CHECKER_UUIDS) {
+      assertThat(CheckerUuid.isUuid(invalidCheckerUuid)).isFalse();
+    }
+  }
+
+  @Test
+  public void checkUuid() {
+    // valid UUIDs
+    assertThat(CheckerUuid.checkUuid("437ee373885fbc47b103dc722800448320e8bc61"))
+        .isEqualTo("437ee373885fbc47b103dc722800448320e8bc61");
+
+    String checkerUuid = CheckerUuid.make("my-checker");
+    assertThat(CheckerUuid.checkUuid(checkerUuid)).isEqualTo(checkerUuid);
+
+    // invalid UUIDs
+    assertThatCheckUuidThrowsIllegalStateExceptionFor(null);
+    for (String invalidCheckerUuid : INVALID_CHECKER_UUIDS) {
+      assertThatCheckUuidThrowsIllegalStateExceptionFor(invalidCheckerUuid);
+    }
+  }
+
+  private void assertThatCheckUuidThrowsIllegalStateExceptionFor(@Nullable String checkerUuid) {
+    try {
+      CheckerUuid.checkUuid(checkerUuid);
+      assert_()
+          .fail("expected IllegalStateException when checking checker UUID \"%s\"", checkerUuid);
+    } catch (IllegalStateException e) {
+      assertThat(e.getMessage()).isEqualTo(String.format("invalid checker UUID: %s", checkerUuid));
+    }
+  }
+
+  @Test
+  public void fromRef() throws Exception {
+    // valid checker refs
+    assertThat(CheckerUuid.fromRef("refs/checkers/43/437ee373885fbc47b103dc722800448320e8bc61"))
+        .hasValue("437ee373885fbc47b103dc722800448320e8bc61");
+
+    String checkerUuid = CheckerUuid.make("my-checker");
+    assertThat(CheckerUuid.fromRef(CheckerRef.refsCheckers(checkerUuid))).hasValue(checkerUuid);
+
+    // invalid checker refs
+    assertThat(CheckerUuid.fromRef((Ref) null)).isEmpty();
+    assertThat(CheckerUuid.fromRef((String) null)).isEmpty();
+    assertThat(CheckerUuid.fromRef("")).isEmpty();
+    assertThat(CheckerUuid.fromRef("refs/checkers/437ee373885fbc47b103dc722800448320e8bc61"))
+        .isEmpty();
+    assertThat(CheckerUuid.fromRef("refs/checkers/61/437ee373885fbc47b103dc722800448320e8bc61"))
+        .isEmpty();
+    assertThat(CheckerUuid.fromRef("refs/checker/43/437ee373885fbc47b103dc722800448320e8bc61"))
+        .isEmpty();
+    assertThat(CheckerUuid.fromRef("refs/checker/43/7ee373885fbc47b103dc722800448320e8bc61"))
+        .isEmpty();
+    assertThat(CheckerUuid.fromRef("refs/checkers/foo")).isEmpty();
+    assertThat(CheckerUuid.fromRef("refs/groups/43/437ee373885fbc47b103dc722800448320e8bc61"))
+        .isEmpty();
+  }
+}
diff --git a/javatests/com/google/gerrit/plugins/checks/acceptance/BUILD b/javatests/com/google/gerrit/plugins/checks/acceptance/BUILD
new file mode 100644
index 0000000..e279935
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/checks/acceptance/BUILD
@@ -0,0 +1,15 @@
+package(default_visibility = ["//plugins/checks:visibility"])
+
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["*IT.java"]),
+    group = "checks",
+    deps = [
+        "//java/com/google/gerrit/server/util/time",
+        "//javatests/com/google/gerrit/acceptance/rest/util",
+        "//plugins/checks:checks__plugin",
+        "//plugins/checks/java/com/google/gerrit/plugins/checks/acceptance",
+        "//plugins/checks/java/com/google/gerrit/plugins/checks/acceptance/testsuite",
+    ],
+)
diff --git a/javatests/com/google/gerrit/plugins/checks/acceptance/CheckerOperationsImplTest.java b/javatests/com/google/gerrit/plugins/checks/acceptance/CheckerOperationsImplTest.java
new file mode 100644
index 0000000..5f21c9e
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/checks/acceptance/CheckerOperationsImplTest.java
@@ -0,0 +1,388 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.checks.acceptance;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+
+import com.google.common.base.Joiner;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.plugins.checks.CheckerRef;
+import com.google.gerrit.plugins.checks.CheckerUuid;
+import com.google.gerrit.plugins.checks.acceptance.testsuite.CheckerOperationsImpl;
+import com.google.gerrit.plugins.checks.acceptance.testsuite.TestChecker;
+import com.google.gerrit.plugins.checks.api.CheckerInfo;
+import com.google.gerrit.plugins.checks.api.CheckerInput;
+import com.google.gerrit.plugins.checks.api.CheckerStatus;
+import com.google.gerrit.plugins.checks.db.CheckerConfig;
+import com.google.gerrit.plugins.checks.db.CheckersByRepositoryNotes;
+import com.google.gerrit.reviewdb.client.Project;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.Optional;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
+import org.junit.Before;
+import org.junit.Test;
+
+public class CheckerOperationsImplTest extends AbstractCheckersTest {
+  // Use specific subclass instead of depending on the interface field from the base class.
+  private CheckerOperationsImpl checkerOperations;
+
+  @Before
+  public void setUp() {
+    checkerOperations = plugin.getSysInjector().getInstance(CheckerOperationsImpl.class);
+  }
+
+  @Test
+  public void checkerCanBeCreatedWithoutSpecifyingAnyParameters() throws Exception {
+    String checkerUuid = checkerOperations.newChecker().create();
+
+    CheckerInfo foundChecker = getCheckerFromServer(checkerUuid);
+    assertThat(foundChecker.uuid).isEqualTo(checkerUuid);
+    assertThat(foundChecker.name).isNotEmpty();
+    assertThat(foundChecker.description).isNull();
+    assertThat(foundChecker.createdOn).isNotNull();
+  }
+
+  @Test
+  public void twoCheckersWithoutAnyParametersDoNotClash() throws Exception {
+    String checkerUuid1 = checkerOperations.newChecker().create();
+    String checkerUuid2 = checkerOperations.newChecker().create();
+
+    TestChecker checker1 = checkerOperations.checker(checkerUuid1).get();
+    TestChecker checker2 = checkerOperations.checker(checkerUuid2).get();
+    assertThat(checker1.uuid()).isNotEqualTo(checker2.uuid());
+  }
+
+  @Test
+  public void checkerCreatedByTestApiCanBeRetrievedViaOfficialApi() throws Exception {
+    String checkerUuid = checkerOperations.newChecker().create();
+
+    CheckerInfo foundChecker = getCheckerFromServer(checkerUuid);
+    assertThat(foundChecker.uuid).isEqualTo(checkerUuid);
+  }
+
+  @Test
+  public void specifiedNameIsRespectedForCheckerCreation() throws Exception {
+    String checkerUuid =
+        checkerOperations.newChecker().name("XYZ-123-this-name-must-be-unique").create();
+
+    CheckerInfo checker = getCheckerFromServer(checkerUuid);
+    assertThat(checker.name).isEqualTo("XYZ-123-this-name-must-be-unique");
+  }
+
+  @Test
+  public void specifiedDescriptionIsRespectedForCheckerCreation() throws Exception {
+    String checkerUuid = checkerOperations.newChecker().description("A simple checker.").create();
+
+    CheckerInfo checker = getCheckerFromServer(checkerUuid);
+    assertThat(checker.description).isEqualTo("A simple checker.");
+  }
+
+  @Test
+  public void requestingNoDescriptionIsPossibleForCheckerCreation() throws Exception {
+    String checkerUuid = checkerOperations.newChecker().clearDescription().create();
+
+    CheckerInfo checker = getCheckerFromServer(checkerUuid);
+    assertThat(checker.description).isNull();
+  }
+
+  @Test
+  public void existingCheckerCanBeCheckedForExistence() throws Exception {
+    String checkerUuid = createCheckerInServer(createArbitraryCheckerInput());
+
+    boolean exists = checkerOperations.checker(checkerUuid).exists();
+
+    assertThat(exists).isTrue();
+  }
+
+  @Test
+  public void notExistingCheckerCanBeCheckedForExistence() throws Exception {
+    String notExistingCheckerUuid = "not-existing-checker";
+
+    boolean exists = checkerOperations.checker(notExistingCheckerUuid).exists();
+
+    assertThat(exists).isFalse();
+  }
+
+  @Test
+  public void retrievingNotExistingCheckerFails() throws Exception {
+    String notExistingCheckerUuid = "not-existing-checker";
+
+    exception.expect(IllegalStateException.class);
+    checkerOperations.checker(notExistingCheckerUuid).get();
+  }
+
+  @Test
+  public void checkerNotCreatedByTestApiCanBeRetrieved() throws Exception {
+    CheckerInput input = createArbitraryCheckerInput();
+    input.name = "unique checker not created via test API";
+    String checkerUuid = createCheckerInServer(input);
+
+    TestChecker foundChecker = checkerOperations.checker(checkerUuid).get();
+
+    assertThat(foundChecker.uuid()).isEqualTo(checkerUuid);
+    assertThat(foundChecker.name()).isEqualTo("unique checker not created via test API");
+  }
+
+  @Test
+  public void uuidOfExistingCheckerCanBeRetrieved() throws Exception {
+    String checkerUuid = checkerOperations.newChecker().create();
+
+    String foundCheckerUuid = checkerOperations.checker(checkerUuid).get().uuid();
+
+    assertThat(foundCheckerUuid).isEqualTo(checkerUuid);
+  }
+
+  @Test
+  public void nameOfExistingCheckerCanBeRetrieved() throws Exception {
+    String checkerUuid =
+        checkerOperations.newChecker().name("ABC-789-this-name-must-be-unique").create();
+
+    String checkerName = checkerOperations.checker(checkerUuid).get().name();
+
+    assertThat(checkerName).isEqualTo("ABC-789-this-name-must-be-unique");
+  }
+
+  @Test
+  public void descriptionOfExistingCheckerCanBeRetrieved() throws Exception {
+    String checkerUuid =
+        checkerOperations
+            .newChecker()
+            .description("This is a very detailed description of this checker.")
+            .create();
+
+    Optional<String> description = checkerOperations.checker(checkerUuid).get().description();
+
+    assertThat(description).hasValue("This is a very detailed description of this checker.");
+  }
+
+  @Test
+  public void emptyDescriptionOfExistingCheckerCanBeRetrieved() throws Exception {
+    String checkerUuid = checkerOperations.newChecker().clearDescription().create();
+
+    Optional<String> description = checkerOperations.checker(checkerUuid).get().description();
+
+    assertThat(description).isEmpty();
+  }
+
+  @Test
+  public void createdOnOfExistingCheckerCanBeRetrieved() throws Exception {
+    CheckerInfo checker = checkersApi.create(createArbitraryCheckerInput()).get();
+
+    Timestamp createdOn = checkerOperations.checker(checker.uuid).get().createdOn();
+
+    assertThat(createdOn).isEqualTo(checker.createdOn);
+  }
+
+  @Test
+  public void updateWithoutAnyParametersIsANoop() throws Exception {
+    String checkerUuid = checkerOperations.newChecker().create();
+    TestChecker originalChecker = checkerOperations.checker(checkerUuid).get();
+
+    checkerOperations.checker(checkerUuid).forUpdate().update();
+
+    TestChecker updatedChecker = checkerOperations.checker(checkerUuid).get();
+    assertThat(updatedChecker).isEqualTo(originalChecker);
+  }
+
+  @Test
+  public void updateWritesToInternalCheckerSystem() throws Exception {
+    String checkerUuid =
+        checkerOperations.newChecker().description("original description").create();
+
+    checkerOperations.checker(checkerUuid).forUpdate().description("updated description").update();
+
+    String currentDescription = getCheckerFromServer(checkerUuid).description;
+    assertThat(currentDescription).isEqualTo("updated description");
+  }
+
+  @Test
+  public void nameCanBeUpdated() throws Exception {
+    String checkerUuid = checkerOperations.newChecker().name("original name").create();
+
+    checkerOperations.checker(checkerUuid).forUpdate().name("updated name").update();
+
+    String currentName = checkerOperations.checker(checkerUuid).get().name();
+    assertThat(currentName).isEqualTo("updated name");
+  }
+
+  @Test
+  public void descriptionCanBeUpdated() throws Exception {
+    String checkerUuid =
+        checkerOperations.newChecker().description("original description").create();
+
+    checkerOperations.checker(checkerUuid).forUpdate().description("updated description").update();
+
+    Optional<String> currentDescription =
+        checkerOperations.checker(checkerUuid).get().description();
+    assertThat(currentDescription).hasValue("updated description");
+  }
+
+  @Test
+  public void descriptionCanBeCleared() throws Exception {
+    String checkerUuid =
+        checkerOperations.newChecker().description("original description").create();
+
+    checkerOperations.checker(checkerUuid).forUpdate().clearDescription().update();
+
+    Optional<String> currentDescription =
+        checkerOperations.checker(checkerUuid).get().description();
+    assertThat(currentDescription).isEmpty();
+  }
+
+  @Test
+  public void statusCanBeUpdated() throws Exception {
+    String checkerUuid =
+        checkerOperations.newChecker().description("original description").create();
+    assertThat(checkerOperations.checker(checkerUuid).asInfo().status)
+        .isEqualTo(CheckerStatus.ENABLED);
+
+    checkerOperations.checker(checkerUuid).forUpdate().disable().update();
+    assertThat(checkerOperations.checker(checkerUuid).asInfo().status)
+        .isEqualTo(CheckerStatus.DISABLED);
+
+    checkerOperations.checker(checkerUuid).forUpdate().enable().update();
+    assertThat(checkerOperations.checker(checkerUuid).asInfo().status)
+        .isEqualTo(CheckerStatus.ENABLED);
+  }
+
+  @Test
+  public void getCommit() throws Exception {
+    CheckerInfo checker = checkersApi.create(createArbitraryCheckerInput()).get();
+
+    RevCommit commit = checkerOperations.checker(checker.uuid).commit();
+    assertThat(commit).isEqualTo(readCheckerCommitSha1(checker.uuid));
+  }
+
+  private ObjectId readCheckerCommitSha1(String checkerUuid) throws IOException {
+    try (Repository repo = repoManager.openRepository(allProjects)) {
+      return repo.exactRef(CheckerRef.refsCheckers(checkerUuid)).getObjectId();
+    }
+  }
+
+  @Test
+  public void getConfigText() throws Exception {
+    CheckerInfo checker = checkersApi.create(createArbitraryCheckerInput()).get();
+
+    String configText = checkerOperations.checker(checker.uuid).configText();
+    assertThat(configText).isEqualTo(readCheckerConfigFile(checker.uuid));
+  }
+
+  private String readCheckerConfigFile(String checkerUuid) throws IOException {
+    try (Repository repo = repoManager.openRepository(allProjects);
+        RevWalk rw = new RevWalk(repo);
+        ObjectReader or = repo.newObjectReader()) {
+      Ref checkerRef = repo.exactRef(CheckerRef.refsCheckers(checkerUuid));
+      RevCommit commit = rw.parseCommit(checkerRef.getObjectId());
+      try (TreeWalk tw =
+          TreeWalk.forPath(or, CheckerConfig.CHECKER_CONFIG_FILE, commit.getTree())) {
+        return new String(or.open(tw.getObjectId(0), OBJ_BLOB).getBytes(), UTF_8);
+      }
+    }
+  }
+
+  @Test
+  public void asInfo() throws Exception {
+    String checkerUuid =
+        checkerOperations
+            .newChecker()
+            .name("my-checker")
+            .description("A description.")
+            .url("http://example.com/my-checker")
+            .create();
+    TestChecker checker = checkerOperations.checker(checkerUuid).get();
+    CheckerInfo checkerInfo = checkerOperations.checker(checkerUuid).asInfo();
+    assertThat(checkerInfo.uuid).isEqualTo(checker.uuid());
+    assertThat(checkerInfo.name).isEqualTo(checker.name());
+    assertThat(checkerInfo.description).isEqualTo(checker.description().get());
+    assertThat(checkerInfo.url).isEqualTo(checker.url().get());
+    assertThat(checkerInfo.createdOn).isEqualTo(checker.createdOn());
+    assertThat(checkerInfo.updatedOn).isEqualTo(checker.updatedOn());
+  }
+
+  @Test
+  public void getCheckersOfRepository() throws Exception {
+    String checkerUuid1 = CheckerUuid.make("my-checker1");
+    String checkerUuid2 = CheckerUuid.make("my-checker2");
+
+    try (Repository repo = repoManager.openRepository(allProjects)) {
+      new TestRepository<>(repo)
+          .branch(CheckerRef.REFS_META_CHECKERS)
+          .commit()
+          .add(
+              CheckersByRepositoryNotes.computeRepositorySha1(project).getName(),
+              Joiner.on('\n').join(checkerUuid1, checkerUuid2))
+          .create();
+    }
+
+    assertThat(checkerOperations.checkersOf(project)).containsExactly(checkerUuid1, checkerUuid2);
+  }
+
+  @Test
+  public void getCheckersOfRepositoryWithoutCheckers() throws Exception {
+    assertThat(checkerOperations.checkersOf(project)).isEmpty();
+  }
+
+  @Test
+  public void getCheckersOfNonExistingRepositor() throws Exception {
+    assertThat(checkerOperations.checkersOf(new Project.NameKey("non-existing"))).isEmpty();
+  }
+
+  @Test
+  public void getSha1sOfRepositoriesWithCheckers() throws Exception {
+    String checkerUuid1 = CheckerUuid.make("my-checker1");
+    String checkerUuid2 = CheckerUuid.make("my-checker2");
+
+    try (Repository repo = repoManager.openRepository(allProjects)) {
+      new TestRepository<>(repo)
+          .branch(CheckerRef.REFS_META_CHECKERS)
+          .commit()
+          .add(CheckersByRepositoryNotes.computeRepositorySha1(project).getName(), checkerUuid1)
+          .add(CheckersByRepositoryNotes.computeRepositorySha1(allProjects).getName(), checkerUuid2)
+          .create();
+    }
+
+    assertThat(checkerOperations.sha1sOfRepositoriesWithCheckers())
+        .containsExactly(
+            CheckersByRepositoryNotes.computeRepositorySha1(project),
+            CheckersByRepositoryNotes.computeRepositorySha1(allProjects));
+  }
+
+  private CheckerInput createArbitraryCheckerInput() {
+    CheckerInput checkerInput = new CheckerInput();
+    checkerInput.name = name("test-checker");
+    checkerInput.repository = allProjects.get();
+    return checkerInput;
+  }
+
+  private CheckerInfo getCheckerFromServer(String checkerUuid) throws RestApiException {
+    return checkersApi.id(checkerUuid).get();
+  }
+
+  private String createCheckerInServer(CheckerInput input) throws RestApiException {
+    CheckerInfo checker = checkersApi.create(input).get();
+    return checker.uuid;
+  }
+}
diff --git a/javatests/com/google/gerrit/plugins/checks/acceptance/CheckerRefsIT.java b/javatests/com/google/gerrit/plugins/checks/acceptance/CheckerRefsIT.java
new file mode 100644
index 0000000..fa02f71
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/checks/acceptance/CheckerRefsIT.java
@@ -0,0 +1,195 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.checks.acceptance;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.GitUtil.deleteRef;
+import static com.google.gerrit.acceptance.GitUtil.fetch;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.SkipProjectClone;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.plugins.checks.CheckerRef;
+import com.google.gerrit.plugins.checks.CheckerUuid;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.change.ChangeInserter;
+import com.google.gerrit.server.notedb.Sequences;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.inject.Inject;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.PushResult;
+import org.eclipse.jgit.transport.RemoteRefUpdate;
+import org.junit.Test;
+
+@SkipProjectClone
+public class CheckerRefsIT extends AbstractCheckersTest {
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private Sequences seq;
+  @Inject private ChangeInserter.Factory changeInserterFactory;
+  @Inject private BatchUpdate.Factory updateFactory;
+
+  @Test
+  public void cannotCreateCheckerRef() throws Exception {
+    grant(allProjects, CheckerRef.REFS_CHECKERS + "*", Permission.CREATE);
+    grant(allProjects, CheckerRef.REFS_CHECKERS + "*", Permission.PUSH);
+
+    String checkerRef = CheckerRef.refsCheckers(CheckerUuid.make("my-checker"));
+
+    TestRepository<InMemoryRepository> testRepo = cloneProject(allProjects);
+    PushOneCommit.Result r = pushFactory.create(admin.getIdent(), testRepo).to(checkerRef);
+    r.assertErrorStatus();
+    assertThat(r.getMessage()).contains("Not allowed to create checker ref.");
+
+    try (Repository repo = repoManager.openRepository(allProjects)) {
+      assertThat(repo.exactRef(checkerRef)).isNull();
+    }
+  }
+
+  @Test
+  public void cannotDeleteCheckerRef() throws Exception {
+    grant(allProjects, CheckerRef.REFS_CHECKERS + "*", Permission.DELETE, true, REGISTERED_USERS);
+
+    String checkerUuid = checkerOperations.newChecker().create();
+    String checkerRef = CheckerRef.refsCheckers(checkerUuid);
+
+    TestRepository<InMemoryRepository> testRepo = cloneProject(allProjects);
+    PushResult r = deleteRef(testRepo, checkerRef);
+    RemoteRefUpdate refUpdate = r.getRemoteUpdate(checkerRef);
+    assertThat(refUpdate.getStatus()).isEqualTo(RemoteRefUpdate.Status.REJECTED_OTHER_REASON);
+    assertThat(refUpdate.getMessage()).contains("Not allowed to delete checker ref.");
+
+    try (Repository repo = repoManager.openRepository(allProjects)) {
+      assertThat(repo.exactRef(checkerRef)).isNotNull();
+    }
+  }
+
+  @Test
+  public void updateCheckerRefsByPushIsDisabled() throws Exception {
+    String checkerUuid = checkerOperations.newChecker().create();
+    String checkerRef = CheckerRef.refsCheckers(checkerUuid);
+
+    TestRepository<InMemoryRepository> repo = cloneProject(allProjects, admin);
+    fetch(repo, checkerRef + ":checkerRef");
+    repo.reset("checkerRef");
+
+    grant(allProjects, CheckerRef.REFS_CHECKERS + "*", Permission.PUSH);
+    PushOneCommit.Result r = pushFactory.create(admin.getIdent(), repo).to(checkerRef);
+    r.assertErrorStatus();
+    r.assertMessage("direct update of checker ref not allowed");
+  }
+
+  @Test
+  public void submitToCheckerRefsIsDisabled() throws Exception {
+    String checkerUuid = checkerOperations.newChecker().create();
+    String checkerRef = CheckerRef.refsCheckers(checkerUuid);
+
+    String changeId = createChangeWithoutCommitValidation(checkerRef);
+
+    grantLabel(
+        "Code-Review",
+        -2,
+        2,
+        allProjects,
+        CheckerRef.REFS_CHECKERS + "*",
+        false,
+        adminGroupUuid(),
+        false);
+    approve(changeId);
+
+    grant(allProjects, CheckerRef.REFS_CHECKERS + "*", Permission.SUBMIT);
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("submit to checker ref not allowed");
+    gApi.changes().id(changeId).current().submit();
+  }
+
+  @Test
+  public void createChangeForCheckerRefsByPushIsDisabled() throws Exception {
+    String checkerUuid = checkerOperations.newChecker().create();
+    String checkerRef = CheckerRef.refsCheckers(checkerUuid);
+
+    TestRepository<InMemoryRepository> repo = cloneProject(allProjects, admin);
+    fetch(repo, checkerRef + ":checkerRef");
+    repo.reset("checkerRef");
+
+    grant(allProjects, CheckerRef.REFS_CHECKERS + "*", Permission.PUSH);
+    PushOneCommit.Result r =
+        pushFactory.create(admin.getIdent(), repo).to("refs/for/" + checkerRef);
+    r.assertErrorStatus();
+    r.assertMessage("creating change for checker ref not allowed");
+  }
+
+  @Test
+  public void createChangeForCheckerRefsViaApiIsDisabled() throws Exception {
+    String checkerUuid = checkerOperations.newChecker().create();
+    String checkerRef = CheckerRef.refsCheckers(checkerUuid);
+
+    TestRepository<InMemoryRepository> repo = cloneProject(allProjects, admin);
+    fetch(repo, checkerRef + ":checkerRef");
+    repo.reset("checkerRef");
+    RevCommit head = getHead(repo.getRepository(), "HEAD");
+
+    ChangeInput input = new ChangeInput();
+    input.project = allProjects.get();
+    input.branch = checkerRef;
+    input.baseCommit = head.name();
+    input.subject = "A change.";
+
+    exception.expect(ResourceConflictException.class);
+    exception.expectMessage("creating change for checker ref not allowed");
+    gApi.changes().create(input);
+  }
+
+  private String createChangeWithoutCommitValidation(String targetRef) throws Exception {
+    try (Repository git = repoManager.openRepository(allProjects);
+        ObjectInserter oi = git.newObjectInserter();
+        ObjectReader reader = oi.newReader();
+        RevWalk rw = new RevWalk(reader)) {
+      RevCommit head = rw.parseCommit(git.exactRef(targetRef).getObjectId());
+      RevCommit commit =
+          new TestRepository<>(git)
+              .commit()
+              .author(admin.getIdent())
+              .message("A change.")
+              .insertChangeId()
+              .parent(head)
+              .create();
+
+      Change.Id changeId = new Change.Id(seq.nextChangeId());
+      ChangeInserter ins = changeInserterFactory.create(changeId, commit, targetRef);
+      ins.setValidate(false);
+      ins.setMessage(String.format("Uploaded patch set %s.", ins.getPatchSetId().get()));
+      try (BatchUpdate bu =
+          updateFactory.create(
+              allProjects, identifiedUserFactory.create(admin.id), TimeUtil.nowTs())) {
+        bu.setRepository(git, rw, oi);
+        bu.insertChange(ins);
+        bu.execute();
+      }
+      return changeId.toString();
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/plugins/checks/acceptance/ChecksRestApiBindingsIT.java b/javatests/com/google/gerrit/plugins/checks/acceptance/ChecksRestApiBindingsIT.java
new file mode 100644
index 0000000..84f568e
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/checks/acceptance/ChecksRestApiBindingsIT.java
@@ -0,0 +1,33 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.checks.acceptance;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.rest.util.RestApiCallHelper;
+import com.google.gerrit.acceptance.rest.util.RestCall;
+import org.junit.Test;
+
+public class ChecksRestApiBindingsIT extends AbstractCheckersTest {
+  private static final ImmutableList<RestCall> CHECKER_ENDPOINTS =
+      ImmutableList.of(
+          RestCall.get("/plugins/checks/checkers/%s"),
+          RestCall.post("/plugins/checks/checkers/%s"));
+
+  @Test
+  public void checkerEndpoints() throws Exception {
+    String checkerUuid = checkerOperations.newChecker().create();
+    RestApiCallHelper.execute(adminRestSession, CHECKER_ENDPOINTS, checkerUuid);
+  }
+}
diff --git a/javatests/com/google/gerrit/plugins/checks/acceptance/GetCheckerIT.java b/javatests/com/google/gerrit/plugins/checks/acceptance/GetCheckerIT.java
new file mode 100644
index 0000000..c02a485
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/checks/acceptance/GetCheckerIT.java
@@ -0,0 +1,85 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.checks.acceptance;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.plugins.checks.CheckerUuid;
+import com.google.gerrit.plugins.checks.api.CheckerInfo;
+import com.google.inject.Inject;
+import org.junit.Test;
+
+public class GetCheckerIT extends AbstractCheckersTest {
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  @Test
+  public void getChecker() throws Exception {
+    String name = "my-checker";
+    String uuid = checkerOperations.newChecker().name(name).create();
+
+    CheckerInfo info = checkersApi.id(uuid).get();
+    assertThat(info.uuid).isEqualTo(uuid);
+    assertThat(info.name).isEqualTo(name);
+    assertThat(info.description).isNull();
+    assertThat(info.createdOn).isNotNull();
+  }
+
+  @Test
+  public void getCheckerWithDescription() throws Exception {
+    String name = "my-checker";
+    String description = "some description";
+    String uuid = checkerOperations.newChecker().name(name).description(description).create();
+
+    CheckerInfo info = checkersApi.id(uuid).get();
+    assertThat(info.uuid).isEqualTo(uuid);
+    assertThat(info.name).isEqualTo(name);
+    assertThat(info.description).isEqualTo(description);
+    assertThat(info.createdOn).isNotNull();
+  }
+
+  @Test
+  public void getNonExistingCheckerFails() throws Exception {
+    String checkerUuid = CheckerUuid.make("non-existing");
+
+    exception.expect(ResourceNotFoundException.class);
+    exception.expectMessage("Not found: " + checkerUuid);
+    checkersApi.id(checkerUuid);
+  }
+
+  @Test
+  public void getCheckerByNameFails() throws Exception {
+    String name = "my-checker";
+    checkerOperations.newChecker().name(name).create();
+
+    exception.expect(ResourceNotFoundException.class);
+    exception.expectMessage("Not found: " + name);
+    checkersApi.id(name);
+  }
+
+  @Test
+  public void getCheckerWithoutAdministrateCheckersCapabilityFails() throws Exception {
+    String name = "my-checker";
+    String uuid = checkerOperations.newChecker().name(name).create();
+
+    requestScopeOperations.setApiUser(user.getId());
+
+    exception.expect(AuthException.class);
+    exception.expectMessage("administrateCheckers for plugin checks not permitted");
+    checkersApi.id(uuid);
+  }
+}
diff --git a/javatests/com/google/gerrit/plugins/checks/acceptance/api/BUILD b/javatests/com/google/gerrit/plugins/checks/acceptance/api/BUILD
new file mode 100644
index 0000000..4404551
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/checks/acceptance/api/BUILD
@@ -0,0 +1,17 @@
+load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+
+acceptance_tests(
+    srcs = glob(["*IT.java"]),
+    group = "api_checker",
+    labels = [
+        "api",
+        "noci",
+    ],
+    deps = [
+        "//java/com/google/gerrit/server/testing",
+        "//java/com/google/gerrit/server/util/time",
+        "//plugins/checks:checks__plugin",
+        "//plugins/checks/java/com/google/gerrit/plugins/checks/acceptance",
+        "//plugins/checks/java/com/google/gerrit/plugins/checks/acceptance/testsuite",
+    ],
+)
diff --git a/javatests/com/google/gerrit/plugins/checks/acceptance/api/CreateCheckerIT.java b/javatests/com/google/gerrit/plugins/checks/acceptance/api/CreateCheckerIT.java
new file mode 100644
index 0000000..1e3daf6
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/checks/acceptance/api/CreateCheckerIT.java
@@ -0,0 +1,305 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.checks.acceptance.api;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.testing.CommitSubject.assertCommit;
+
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.plugins.checks.acceptance.AbstractCheckersTest;
+import com.google.gerrit.plugins.checks.acceptance.testsuite.CheckerOperations.PerCheckerOperations;
+import com.google.gerrit.plugins.checks.api.CheckerInfo;
+import com.google.gerrit.plugins.checks.api.CheckerInput;
+import com.google.gerrit.plugins.checks.api.CheckerStatus;
+import com.google.gerrit.plugins.checks.db.CheckersByRepositoryNotes;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.testing.TestTimeUtil;
+import com.google.inject.Inject;
+import java.util.concurrent.TimeUnit;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class CreateCheckerIT extends AbstractCheckersTest {
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ProjectOperations projectOperations;
+
+  @Before
+  public void setTimeForTesting() {
+    TestTimeUtil.resetWithClockStep(1, TimeUnit.SECONDS);
+  }
+
+  @After
+  public void resetTime() {
+    TestTimeUtil.useSystemTime();
+  }
+
+  @Test
+  public void createChecker() throws Exception {
+    Project.NameKey repositoryName = projectOperations.newProject().create();
+
+    CheckerInput input = new CheckerInput();
+    input.name = "my-checker";
+    input.repository = repositoryName.get();
+    CheckerInfo info = checkersApi.create(input).get();
+    assertThat(info.uuid).isNotNull();
+    assertThat(info.name).isEqualTo(input.name);
+    assertThat(info.description).isNull();
+    assertThat(info.url).isNull();
+    assertThat(info.repository).isEqualTo(input.repository);
+    assertThat(info.status).isEqualTo(CheckerStatus.ENABLED);
+    assertThat(info.createdOn).isNotNull();
+    assertThat(info.updatedOn).isEqualTo(info.createdOn);
+
+    PerCheckerOperations perCheckerOps = checkerOperations.checker(info.uuid);
+    assertCommit(
+        perCheckerOps.commit(), "Create checker", info.createdOn, perCheckerOps.get().refState());
+    assertThat(checkerOperations.sha1sOfRepositoriesWithCheckers())
+        .containsExactly(CheckersByRepositoryNotes.computeRepositorySha1(repositoryName));
+    assertThat(checkerOperations.checkersOf(repositoryName)).containsExactly(info.uuid);
+  }
+
+  @Test
+  public void createCheckerWithDescription() throws Exception {
+    CheckerInput input = new CheckerInput();
+    input.name = "my-checker";
+    input.description = "some description";
+    input.repository = allProjects.get();
+    CheckerInfo info = checkersApi.create(input).get();
+    assertThat(info.description).isEqualTo(input.description);
+
+    PerCheckerOperations perCheckerOps = checkerOperations.checker(info.uuid);
+    assertCommit(
+        perCheckerOps.commit(), "Create checker", info.createdOn, perCheckerOps.get().refState());
+  }
+
+  @Test
+  public void createCheckerWithUrl() throws Exception {
+    CheckerInput input = new CheckerInput();
+    input.name = "my-checker";
+    input.url = "http://example.com/my-checker";
+    input.repository = allProjects.get();
+    CheckerInfo info = checkersApi.create(input).get();
+    assertThat(info.url).isEqualTo(input.url);
+
+    PerCheckerOperations perCheckerOps = checkerOperations.checker(info.uuid);
+    assertCommit(
+        perCheckerOps.commit(), "Create checker", info.createdOn, perCheckerOps.get().refState());
+  }
+
+  @Test
+  public void createCheckerNameIsTrimmed() throws Exception {
+    CheckerInput input = new CheckerInput();
+    input.name = " my-checker ";
+    input.repository = allProjects.get();
+    CheckerInfo info = checkersApi.create(input).get();
+    assertThat(info.name).isEqualTo("my-checker");
+
+    PerCheckerOperations perCheckerOps = checkerOperations.checker(info.uuid);
+    assertCommit(
+        perCheckerOps.commit(), "Create checker", info.createdOn, perCheckerOps.get().refState());
+  }
+
+  @Test
+  public void createCheckerDescriptionIsTrimmed() throws Exception {
+    CheckerInput input = new CheckerInput();
+    input.name = "my-checker";
+    input.description = " some description ";
+    input.repository = allProjects.get();
+    CheckerInfo info = checkersApi.create(input).get();
+    assertThat(info.description).isEqualTo("some description");
+
+    PerCheckerOperations perCheckerOps = checkerOperations.checker(info.uuid);
+    assertCommit(
+        perCheckerOps.commit(), "Create checker", info.createdOn, perCheckerOps.get().refState());
+  }
+
+  @Test
+  public void createCheckerUrlIsTrimmed() throws Exception {
+    CheckerInput input = new CheckerInput();
+    input.name = "my-checker";
+    input.url = " http://example.com/my-checker ";
+    input.repository = allProjects.get();
+    CheckerInfo info = checkersApi.create(input).get();
+    assertThat(info.url).isEqualTo("http://example.com/my-checker");
+
+    PerCheckerOperations perCheckerOps = checkerOperations.checker(info.uuid);
+    assertCommit(
+        perCheckerOps.commit(), "Create checker", info.createdOn, perCheckerOps.get().refState());
+  }
+
+  @Test
+  public void createCheckerRepositoryIsTrimmed() throws Exception {
+    CheckerInput input = new CheckerInput();
+    input.name = "my-checker";
+    input.repository = " " + allProjects.get() + " ";
+    CheckerInfo info = checkersApi.create(input).get();
+    assertThat(info.repository).isEqualTo(allProjects.get());
+
+    PerCheckerOperations perCheckerOps = checkerOperations.checker(info.uuid);
+    assertCommit(
+        perCheckerOps.commit(), "Create checker", info.createdOn, perCheckerOps.get().refState());
+  }
+
+  @Test
+  public void createCheckerWithInvalidUrlFails() throws Exception {
+    String checkerUuid = checkerOperations.newChecker().name("my-checker").create();
+
+    CheckerInput input = new CheckerInput();
+    input.name = "my-checker";
+    input.url = "ftp://example.com/my-checker";
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("only http/https URLs supported: ftp://example.com/my-checker");
+    checkersApi.id(checkerUuid).update(input);
+  }
+
+  @Test
+  public void createCheckersWithSameName() throws Exception {
+    CheckerInput input = new CheckerInput();
+    input.name = "my-checker";
+    input.repository = allProjects.get();
+    CheckerInfo info1 = checkersApi.create(input).get();
+    assertThat(info1.name).isEqualTo(input.name);
+
+    CheckerInfo info2 = checkersApi.create(input).get();
+    assertThat(info2.name).isEqualTo(input.name);
+
+    assertThat(info2.uuid).isNotEqualTo(info1.uuid);
+  }
+
+  @Test
+  public void createCheckerWithoutNameFails() throws Exception {
+    CheckerInput input = new CheckerInput();
+    input.repository = allProjects.get();
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("name is required");
+    checkersApi.create(input);
+  }
+
+  @Test
+  public void createCheckerWithEmptyNameFails() throws Exception {
+    CheckerInput input = new CheckerInput();
+    input.name = "";
+    input.repository = allProjects.get();
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("name is required");
+    checkersApi.create(input);
+  }
+
+  @Test
+  public void createCheckerWithEmptyNameAfterTrimFails() throws Exception {
+    CheckerInput input = new CheckerInput();
+    input.name = " ";
+    input.repository = allProjects.get();
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("name is required");
+    checkersApi.create(input);
+  }
+
+  @Test
+  public void createCheckerWithoutRepositoryFails() throws Exception {
+    CheckerInput input = new CheckerInput();
+    input.name = "my-checker";
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("repository is required");
+    checkersApi.create(input);
+  }
+
+  @Test
+  public void createCheckerWithEmptyRepositoryFails() throws Exception {
+    CheckerInput input = new CheckerInput();
+    input.name = "my-checker";
+    input.repository = "";
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("repository is required");
+    checkersApi.create(input);
+  }
+
+  @Test
+  public void createCheckerWithEmptyRepositoryAfterTrimFails() throws Exception {
+    CheckerInput input = new CheckerInput();
+    input.name = "my-checker";
+    input.repository = " ";
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("repository is required");
+    checkersApi.create(input);
+  }
+
+  @Test
+  public void createCheckerWithNonExistingRepositoryFails() throws Exception {
+    CheckerInput input = new CheckerInput();
+    input.name = "my-checker";
+    input.repository = "non-existing";
+
+    exception.expect(UnprocessableEntityException.class);
+    exception.expectMessage("repository non-existing not found");
+    checkersApi.create(input);
+  }
+
+  @Test
+  public void createDisabledChecker() throws Exception {
+    CheckerInput input = new CheckerInput();
+    input.name = "my-checker";
+    input.repository = allProjects.get();
+    input.status = CheckerStatus.DISABLED;
+
+    CheckerInfo info = checkersApi.create(input).get();
+    assertThat(info.status).isEqualTo(CheckerStatus.DISABLED);
+  }
+
+  @Test
+  public void createMultipleCheckers() throws Exception {
+    Project.NameKey repositoryName1 = projectOperations.newProject().create();
+    Project.NameKey repositoryName2 = projectOperations.newProject().create();
+
+    String checkerUuid1 = checkerOperations.newChecker().repository(repositoryName1).create();
+    String checkerUuid2 = checkerOperations.newChecker().repository(repositoryName1).create();
+    String checkerUuid3 = checkerOperations.newChecker().repository(repositoryName1).create();
+    String checkerUuid4 = checkerOperations.newChecker().repository(repositoryName2).create();
+    String checkerUuid5 = checkerOperations.newChecker().repository(repositoryName2).create();
+
+    assertThat(checkerOperations.sha1sOfRepositoriesWithCheckers())
+        .containsExactly(
+            CheckersByRepositoryNotes.computeRepositorySha1(repositoryName1),
+            CheckersByRepositoryNotes.computeRepositorySha1(repositoryName2));
+    assertThat(checkerOperations.checkersOf(repositoryName1))
+        .containsExactly(checkerUuid1, checkerUuid2, checkerUuid3);
+    assertThat(checkerOperations.checkersOf(repositoryName2))
+        .containsExactly(checkerUuid4, checkerUuid5);
+  }
+
+  @Test
+  public void createCheckerWithoutAdministrateCheckersCapabilityFails() throws Exception {
+    requestScopeOperations.setApiUser(user.getId());
+
+    CheckerInput input = new CheckerInput();
+    input.name = "my-checker";
+    input.repository = allProjects.get();
+
+    exception.expect(AuthException.class);
+    exception.expectMessage("administrateCheckers for plugin checks not permitted");
+    checkersApi.create(input);
+  }
+}
diff --git a/javatests/com/google/gerrit/plugins/checks/acceptance/api/ListCheckersIT.java b/javatests/com/google/gerrit/plugins/checks/acceptance/api/ListCheckersIT.java
new file mode 100644
index 0000000..71b7a77
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/checks/acceptance/api/ListCheckersIT.java
@@ -0,0 +1,96 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.checks.acceptance.api;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assert_;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.plugins.checks.CheckerRef;
+import com.google.gerrit.plugins.checks.CheckerUuid;
+import com.google.gerrit.plugins.checks.acceptance.AbstractCheckersTest;
+import com.google.gerrit.plugins.checks.api.CheckerInfo;
+import com.google.gerrit.plugins.checks.db.CheckerConfig;
+import com.google.inject.Inject;
+import java.util.List;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Test;
+
+public class ListCheckersIT extends AbstractCheckersTest {
+  @Inject private RequestScopeOperations requestScopeOperations;
+
+  @Test
+  public void listAll() throws Exception {
+    String checkerUuid1 = checkerOperations.newChecker().name("checker-with-name-only").create();
+    String checkerUuid2 =
+        checkerOperations
+            .newChecker()
+            .name("checker-with-description")
+            .description("A description.")
+            .create();
+    String checkerUuid3 =
+        checkerOperations
+            .newChecker()
+            .name("checker-with-url")
+            .url("http://example.com/my-checker")
+            .create();
+    List<CheckerInfo> expectedCheckerInfos =
+        ImmutableList.of(checkerUuid1, checkerUuid2, checkerUuid3)
+            .stream()
+            .sorted()
+            .map(uuid -> checkerOperations.checker(uuid).asInfo())
+            .collect(toList());
+
+    List<CheckerInfo> allCheckers = checkersApi.all();
+    assertThat(allCheckers).isEqualTo(expectedCheckerInfos);
+  }
+
+  @Test
+  public void listWithoutAdministrateCheckersCapabilityFails() throws Exception {
+    checkerOperations.newChecker().name("my-checker").create();
+
+    requestScopeOperations.setApiUser(user.getId());
+
+    try {
+      checkersApi.all();
+      assert_().fail("expected AuthException");
+    } catch (AuthException e) {
+      assertThat(e.getMessage()).isEqualTo("administrateCheckers for plugin checks not permitted");
+    }
+  }
+
+  @Test
+  public void listIgnoresInvalidCheckers() throws Exception {
+    String checkerUuid = checkerOperations.newChecker().name("checker-with-name-only").create();
+    createInvalidChecker();
+
+    List<CheckerInfo> allCheckers = checkersApi.all();
+    assertThat(allCheckers).containsExactly(checkerOperations.checker(checkerUuid).asInfo());
+  }
+
+  private void createInvalidChecker() throws Exception {
+    try (Repository repo = repoManager.openRepository(allProjects)) {
+      new TestRepository<>(repo)
+          .branch(CheckerRef.refsCheckers(CheckerUuid.make("my-checker")))
+          .commit()
+          .add(CheckerConfig.CHECKER_CONFIG_FILE, "invalid-config")
+          .create();
+    }
+  }
+}
diff --git a/javatests/com/google/gerrit/plugins/checks/acceptance/api/UpdateCheckerIT.java b/javatests/com/google/gerrit/plugins/checks/acceptance/api/UpdateCheckerIT.java
new file mode 100644
index 0000000..f6f3b0e
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/checks/acceptance/api/UpdateCheckerIT.java
@@ -0,0 +1,437 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.checks.acceptance.api;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.testing.CommitSubject.assertCommit;
+
+import com.google.gerrit.acceptance.SkipProjectClone;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.acceptance.testsuite.request.RequestScopeOperations;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.plugins.checks.acceptance.AbstractCheckersTest;
+import com.google.gerrit.plugins.checks.acceptance.testsuite.CheckerOperations.PerCheckerOperations;
+import com.google.gerrit.plugins.checks.acceptance.testsuite.TestChecker;
+import com.google.gerrit.plugins.checks.api.CheckerInfo;
+import com.google.gerrit.plugins.checks.api.CheckerInput;
+import com.google.gerrit.plugins.checks.api.CheckerStatus;
+import com.google.gerrit.plugins.checks.db.CheckersByRepositoryNotes;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.gerrit.testing.TestTimeUtil;
+import com.google.inject.Inject;
+import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.lib.Config;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+@SkipProjectClone
+public class UpdateCheckerIT extends AbstractCheckersTest {
+  @Inject private RequestScopeOperations requestScopeOperations;
+  @Inject private ProjectOperations projectOperations;
+
+  @ConfigSuite.Default
+  public static Config defaultConfig() {
+    Config cfg = new Config();
+    cfg.setBoolean("checks", "api", "enabled", true);
+    return cfg;
+  }
+
+  @Before
+  public void setTimeForTesting() {
+    TestTimeUtil.resetWithClockStep(1, TimeUnit.SECONDS);
+  }
+
+  @After
+  public void resetTime() {
+    TestTimeUtil.useSystemTime();
+  }
+
+  @Test
+  public void updateMultipleCheckerPropertiesAtOnce() throws Exception {
+    String checkerUuid =
+        checkerOperations.newChecker().name("my-checker").repository(allProjects).create();
+    TestChecker checker = checkerOperations.checker(checkerUuid).get();
+
+    Project.NameKey repositoryName = projectOperations.newProject().create();
+
+    CheckerInput input = new CheckerInput();
+    input.name = "my-renamed-checker";
+    input.description = "A description.";
+    input.url = "http://example.com/my-checker";
+    input.repository = repositoryName.get();
+
+    CheckerInfo info = checkersApi.id(checkerUuid).update(input);
+    assertThat(info.uuid).isEqualTo(checkerUuid);
+    assertThat(info.name).isEqualTo(input.name);
+    assertThat(info.description).isEqualTo(input.description);
+    assertThat(info.url).isEqualTo(input.url);
+    assertThat(info.repository).isEqualTo(input.repository);
+    assertThat(info.createdOn).isEqualTo(checker.createdOn());
+    assertThat(info.createdOn).isLessThan(info.updatedOn);
+
+    PerCheckerOperations perCheckerOps = checkerOperations.checker(checkerUuid);
+    assertCommit(
+        perCheckerOps.commit(),
+        "Update checker\n\nRename from my-checker to my-renamed-checker",
+        info.updatedOn,
+        perCheckerOps.get().refState());
+    assertThat(checkerOperations.sha1sOfRepositoriesWithCheckers())
+        .containsExactly(CheckersByRepositoryNotes.computeRepositorySha1(repositoryName));
+    assertThat(checkerOperations.checkersOf(repositoryName)).containsExactly(info.uuid);
+  }
+
+  @Test
+  public void updateCheckerName() throws Exception {
+    String checkerUuid = checkerOperations.newChecker().name("my-checker").create();
+
+    CheckerInput input = new CheckerInput();
+    input.name = "my-renamed-checker";
+
+    CheckerInfo info = checkersApi.id(checkerUuid).update(input);
+    assertThat(info.name).isEqualTo(input.name);
+
+    PerCheckerOperations perCheckerOps = checkerOperations.checker(checkerUuid);
+    assertCommit(
+        perCheckerOps.commit(),
+        "Update checker\n\nRename from my-checker to my-renamed-checker",
+        info.updatedOn,
+        perCheckerOps.get().refState());
+  }
+
+  @Test
+  public void cannotSetCheckerNameToEmptyString() throws Exception {
+    String checkerUuid = checkerOperations.newChecker().name("my-checker").create();
+
+    CheckerInput checkerInput = new CheckerInput();
+    checkerInput.name = "";
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("name cannot be unset");
+    checkersApi.id(checkerUuid).update(checkerInput);
+  }
+
+  @Test
+  public void cannotSetCheckerNameToStringWhichIsEmptyAfterTrim() throws Exception {
+    String checkerUuid = checkerOperations.newChecker().name("my-checker").create();
+
+    CheckerInput checkerInput = new CheckerInput();
+    checkerInput.name = " ";
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("name cannot be unset");
+    checkersApi.id(checkerUuid).update(checkerInput);
+  }
+
+  @Test
+  public void updateCheckerNameToNameThatIsAlreadyUsed() throws Exception {
+    checkerOperations.newChecker().name("other-checker").create();
+
+    String checkerUuid = checkerOperations.newChecker().name("my-checker").create();
+
+    CheckerInput input = new CheckerInput();
+    input.name = "other-checker";
+
+    CheckerInfo info = checkersApi.id(checkerUuid).update(input);
+    assertThat(info.name).isEqualTo(input.name);
+
+    PerCheckerOperations perCheckerOps = checkerOperations.checker(checkerUuid);
+    assertCommit(
+        perCheckerOps.commit(),
+        "Update checker\n\nRename from my-checker to other-checker",
+        info.updatedOn,
+        perCheckerOps.get().refState());
+  }
+
+  @Test
+  public void addCheckerDescription() throws Exception {
+    String checkerUuid = checkerOperations.newChecker().name("my-checker").create();
+
+    CheckerInput input = new CheckerInput();
+    input.description = "A description.";
+
+    CheckerInfo info = checkersApi.id(checkerUuid).update(input);
+    assertThat(info.description).isEqualTo(input.description);
+
+    PerCheckerOperations perCheckerOps = checkerOperations.checker(checkerUuid);
+    assertCommit(
+        perCheckerOps.commit(), "Update checker", info.updatedOn, perCheckerOps.get().refState());
+  }
+
+  @Test
+  public void updateCheckerDescription() throws Exception {
+    String checkerUuid =
+        checkerOperations.newChecker().name("my-checker").description("A description.").create();
+
+    CheckerInput input = new CheckerInput();
+    input.description = "A new description.";
+
+    CheckerInfo info = checkersApi.id(checkerUuid).update(input);
+    assertThat(info.description).isEqualTo(input.description);
+
+    PerCheckerOperations perCheckerOps = checkerOperations.checker(checkerUuid);
+    assertCommit(
+        perCheckerOps.commit(), "Update checker", info.updatedOn, perCheckerOps.get().refState());
+  }
+
+  @Test
+  public void unsetCheckerDescription() throws Exception {
+    String checkerUuid =
+        checkerOperations.newChecker().name("my-checker").description("A description.").create();
+
+    CheckerInput checkerInput = new CheckerInput();
+    checkerInput.description = "";
+
+    CheckerInfo info = checkersApi.id(checkerUuid).update(checkerInput);
+    assertThat(info.description).isNull();
+
+    PerCheckerOperations perCheckerOps = checkerOperations.checker(checkerUuid);
+    assertCommit(
+        perCheckerOps.commit(), "Update checker", info.updatedOn, perCheckerOps.get().refState());
+  }
+
+  @Test
+  public void checkerDescriptionIsTrimmed() throws Exception {
+    String checkerUuid = checkerOperations.newChecker().name("my-checker").create();
+
+    CheckerInput input = new CheckerInput();
+    input.description = " A description. ";
+
+    CheckerInfo info = checkersApi.id(checkerUuid).update(input);
+    assertThat(info.description).isEqualTo("A description.");
+
+    PerCheckerOperations perCheckerOps = checkerOperations.checker(checkerUuid);
+    assertCommit(
+        perCheckerOps.commit(), "Update checker", info.updatedOn, perCheckerOps.get().refState());
+  }
+
+  @Test
+  public void addCheckerUrl() throws Exception {
+    String checkerUuid = checkerOperations.newChecker().name("my-checker").create();
+
+    CheckerInput input = new CheckerInput();
+    input.url = "http://example.com/my-checker";
+
+    CheckerInfo info = checkersApi.id(checkerUuid).update(input);
+    assertThat(info.url).isEqualTo(input.url);
+
+    PerCheckerOperations perCheckerOps = checkerOperations.checker(checkerUuid);
+    assertCommit(
+        perCheckerOps.commit(), "Update checker", info.updatedOn, perCheckerOps.get().refState());
+  }
+
+  @Test
+  public void updateCheckerUrl() throws Exception {
+    String checkerUuid =
+        checkerOperations
+            .newChecker()
+            .name("my-checker")
+            .url("http://example.com/my-checker")
+            .create();
+
+    CheckerInput input = new CheckerInput();
+    input.url = "http://example.com/my-checker-foo";
+
+    CheckerInfo info = checkersApi.id(checkerUuid).update(input);
+    assertThat(info.url).isEqualTo(input.url);
+
+    PerCheckerOperations perCheckerOps = checkerOperations.checker(checkerUuid);
+    assertCommit(
+        perCheckerOps.commit(), "Update checker", info.updatedOn, perCheckerOps.get().refState());
+  }
+
+  @Test
+  public void unsetCheckerUrl() throws Exception {
+    String checkerUuid =
+        checkerOperations
+            .newChecker()
+            .name("my-checker")
+            .url("http://example.com/my-checker")
+            .create();
+
+    CheckerInput checkerInput = new CheckerInput();
+    checkerInput.url = "";
+
+    CheckerInfo info = checkersApi.id(checkerUuid).update(checkerInput);
+    assertThat(info.url).isNull();
+
+    PerCheckerOperations perCheckerOps = checkerOperations.checker(checkerUuid);
+    assertCommit(
+        perCheckerOps.commit(), "Update checker", info.updatedOn, perCheckerOps.get().refState());
+  }
+
+  @Test
+  public void checkerUrlIsTrimmed() throws Exception {
+    String checkerUuid = checkerOperations.newChecker().name("my-checker").create();
+
+    CheckerInput input = new CheckerInput();
+    input.url = " http://example.com/my-checker ";
+
+    CheckerInfo info = checkersApi.id(checkerUuid).update(input);
+    assertThat(info.url).isEqualTo("http://example.com/my-checker");
+
+    PerCheckerOperations perCheckerOps = checkerOperations.checker(checkerUuid);
+    assertCommit(
+        perCheckerOps.commit(), "Update checker", info.updatedOn, perCheckerOps.get().refState());
+  }
+
+  @Test
+  public void updateRepository() throws Exception {
+    String checkerUuid =
+        checkerOperations.newChecker().name("my-checker").repository(allProjects).create();
+
+    Project.NameKey repositoryName = projectOperations.newProject().create();
+
+    CheckerInput input = new CheckerInput();
+    input.repository = repositoryName.get();
+
+    CheckerInfo info = checkersApi.id(checkerUuid).update(input);
+    assertThat(info.repository).isEqualTo(input.repository);
+
+    PerCheckerOperations perCheckerOps = checkerOperations.checker(checkerUuid);
+    assertCommit(
+        perCheckerOps.commit(), "Update checker", info.updatedOn, perCheckerOps.get().refState());
+    assertThat(checkerOperations.sha1sOfRepositoriesWithCheckers())
+        .containsExactly(CheckersByRepositoryNotes.computeRepositorySha1(repositoryName));
+    assertThat(checkerOperations.checkersOf(repositoryName)).containsExactly(info.uuid);
+  }
+
+  @Test
+  public void cannotSetRepositoryToEmptyString() throws Exception {
+    String checkerUuid = checkerOperations.newChecker().create();
+
+    CheckerInput checkerInput = new CheckerInput();
+    checkerInput.repository = "";
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("repository cannot be unset");
+    checkersApi.id(checkerUuid).update(checkerInput);
+  }
+
+  @Test
+  public void cannotSetRepositoryToStringWhichIsEmptyAfterTrim() throws Exception {
+    String checkerUuid = checkerOperations.newChecker().create();
+
+    CheckerInput checkerInput = new CheckerInput();
+    checkerInput.repository = " ";
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("repository cannot be unset");
+    checkersApi.id(checkerUuid).update(checkerInput);
+  }
+
+  @Test
+  public void cannotSetNonExistingRepository() throws Exception {
+    String checkerUuid = checkerOperations.newChecker().create();
+
+    CheckerInput checkerInput = new CheckerInput();
+    checkerInput.repository = "non-existing";
+
+    exception.expect(UnprocessableEntityException.class);
+    exception.expectMessage("repository non-existing not found");
+    checkersApi.id(checkerUuid).update(checkerInput);
+  }
+
+  @Test
+  public void cannotSetUrlToInvalidUrl() throws Exception {
+    String checkerUuid = checkerOperations.newChecker().name("my-checker").create();
+
+    CheckerInput input = new CheckerInput();
+    input.url = "ftp://example.com/my-checker";
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("only http/https URLs supported: ftp://example.com/my-checker");
+    checkersApi.id(checkerUuid).update(input);
+  }
+
+  @Test
+  public void disableAndReenable() throws Exception {
+    String checkerUuid =
+        checkerOperations.newChecker().name("my-checker").repository(allProjects).create();
+    assertThat(checkerOperations.checkersOf(allProjects)).containsExactly(checkerUuid);
+
+    CheckerInput input = new CheckerInput();
+    input.status = CheckerStatus.DISABLED;
+
+    CheckerInfo info = checkersApi.id(checkerUuid).update(input);
+    assertThat(info.status).isEqualTo(CheckerStatus.DISABLED);
+    assertThat(checkerOperations.checkersOf(allProjects)).isEmpty();
+
+    input = new CheckerInput();
+    input.status = CheckerStatus.ENABLED;
+    info = checkersApi.id(checkerUuid).update(input);
+    assertThat(info.status).isEqualTo(CheckerStatus.ENABLED);
+    assertThat(checkerOperations.checkersOf(allProjects)).containsExactly(checkerUuid);
+  }
+
+  @Test
+  public void updateRepositoryDuringDisable() throws Exception {
+    String checkerUuid =
+        checkerOperations.newChecker().name("my-checker").repository(allProjects).create();
+
+    Project.NameKey repositoryName = projectOperations.newProject().create();
+
+    CheckerInput input = new CheckerInput();
+    input.repository = repositoryName.get();
+    input.status = CheckerStatus.DISABLED;
+
+    CheckerInfo info = checkersApi.id(checkerUuid).update(input);
+    assertThat(info.repository).isEqualTo(input.repository);
+    assertThat(info.status).isEqualTo(CheckerStatus.DISABLED);
+    assertThat(checkerOperations.checkersOf(allProjects)).isEmpty();
+  }
+
+  @Test
+  public void updateRepositoryDuringEnable() throws Exception {
+    String checkerUuid =
+        checkerOperations.newChecker().name("my-checker").repository(allProjects).create();
+
+    Project.NameKey repositoryName = projectOperations.newProject().create();
+    assertThat(checkerOperations.checkersOf(allProjects)).containsExactly(checkerUuid);
+    assertThat(checkerOperations.checkersOf(repositoryName)).isEmpty();
+
+    CheckerInput input = new CheckerInput();
+    input.status = CheckerStatus.DISABLED;
+
+    CheckerInfo info = checkersApi.id(checkerUuid).update(input);
+    assertThat(info.status).isEqualTo(CheckerStatus.DISABLED);
+    assertThat(checkerOperations.checkersOf(allProjects)).isEmpty();
+    assertThat(checkerOperations.checkersOf(repositoryName)).isEmpty();
+
+    input = new CheckerInput();
+    input.status = CheckerStatus.ENABLED;
+    input.repository = repositoryName.get();
+    info = checkersApi.id(checkerUuid).update(input);
+    assertThat(info.status).isEqualTo(CheckerStatus.ENABLED);
+    assertThat(checkerOperations.checkersOf(allProjects)).isEmpty();
+    assertThat(checkerOperations.checkersOf(repositoryName)).containsExactly(checkerUuid);
+  }
+
+  @Test
+  public void updateCheckerWithoutAdministrateCheckersCapabilityFails() throws Exception {
+    String checkerUuid = checkerOperations.newChecker().name("my-checker").create();
+
+    requestScopeOperations.setApiUser(user.getId());
+
+    CheckerInput input = new CheckerInput();
+    input.name = "my-renamed-checker";
+
+    exception.expect(AuthException.class);
+    exception.expectMessage("administrateCheckers for plugin checks not permitted");
+    checkersApi.id(checkerUuid).update(input);
+  }
+}
diff --git a/javatests/com/google/gerrit/plugins/checks/db/BUILD b/javatests/com/google/gerrit/plugins/checks/db/BUILD
new file mode 100644
index 0000000..a8d497c
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/checks/db/BUILD
@@ -0,0 +1,28 @@
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+junit_tests(
+    name = "db_tests",
+    size = "small",
+    srcs = glob(["*.java"]),
+    deps = [
+        "//java/com/google/gerrit/common:annotations",
+        "//java/com/google/gerrit/common:server",
+        "//java/com/google/gerrit/common/data/testing:common-data-test-util",
+        "//java/com/google/gerrit/extensions:api",
+        "//java/com/google/gerrit/extensions/common/testing:common-test-util",
+        "//java/com/google/gerrit/git",
+        "//java/com/google/gerrit/reviewdb:server",
+        "//java/com/google/gerrit/server",
+        "//java/com/google/gerrit/server/testing",
+        "//java/com/google/gerrit/server/util/time",
+        "//java/com/google/gerrit/testing:gerrit-test-util",
+        "//java/com/google/gerrit/truth",
+        "//lib:guava",
+        "//lib:gwtorm",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/jgit/org.eclipse.jgit.junit:junit",
+        "//lib/truth",
+        "//plugins/checks:checks__plugin",
+        "//plugins/checks/java/com/google/gerrit/plugins/checks/testing",
+    ],
+)
diff --git a/javatests/com/google/gerrit/plugins/checks/db/CheckerConfigTest.java b/javatests/com/google/gerrit/plugins/checks/db/CheckerConfigTest.java
new file mode 100644
index 0000000..1e560e3
--- /dev/null
+++ b/javatests/com/google/gerrit/plugins/checks/db/CheckerConfigTest.java
@@ -0,0 +1,550 @@
+// Copyright (C) 2019 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.plugins.checks.db;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.plugins.checks.testing.CheckerConfigSubject.assertThat;
+import static org.hamcrest.CoreMatchers.instanceOf;
+
+import com.google.common.truth.StringSubject;
+import com.google.gerrit.plugins.checks.CheckerCreation;
+import com.google.gerrit.plugins.checks.CheckerRef;
+import com.google.gerrit.plugins.checks.CheckerUpdate;
+import com.google.gerrit.plugins.checks.CheckerUuid;
+import com.google.gerrit.plugins.checks.api.CheckerStatus;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.meta.MetaDataUpdate;
+import com.google.gerrit.server.util.time.TimeUtil;
+import com.google.gerrit.testing.GerritBaseTests;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.Month;
+import java.time.ZoneId;
+import java.util.TimeZone;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.junit.Before;
+import org.junit.Test;
+
+public class CheckerConfigTest extends GerritBaseTests {
+  private Project.NameKey projectName;
+  private Repository repository;
+  private TestRepository<?> testRepository;
+
+  private final String checkerName = "my-checker";
+  private final String checkerUuid = CheckerUuid.make(checkerName);
+  private final Project.NameKey checkerRepository = new Project.NameKey("my-repo");
+  private final TimeZone timeZone = TimeZone.getTimeZone("America/Los_Angeles");
+
+  @Before
+  public void setUp() throws Exception {
+    projectName = new Project.NameKey("Test Repository");
+    repository = new InMemoryRepository(new DfsRepositoryDescription("Test Repository"));
+    testRepository = new TestRepository<>(repository);
+  }
+
+  @Test
+  public void correctCommitMessageForCheckerCreation() throws Exception {
+    CheckerCreation checkerCreation =
+        getPrefilledCheckerCreationBuilder().setCheckerUuid(checkerUuid).build();
+    createChecker(checkerCreation);
+    assertThatCommitMessage(checkerUuid).isEqualTo("Create checker");
+  }
+
+  @Test
+  public void specifiedCheckerUuidIsRespectedForNewChecker() throws Exception {
+    CheckerCreation checkerCreation =
+        getPrefilledCheckerCreationBuilder().setCheckerUuid(checkerUuid).build();
+    createChecker(checkerCreation);
+
+    CheckerConfig checkerConfig = loadChecker(checkerUuid);
+    assertThat(checkerConfig).hasUuid(checkerUuid);
+  }
+
+  @Test
+  public void invalidCheckerUuidIsRejectedForNewChecker() throws Exception {
+    CheckerCreation checkerCreation =
+        getPrefilledCheckerCreationBuilder().setCheckerUuid("not-a-SHA1").build();
+
+    exception.expect(IllegalStateException.class);
+    exception.expectMessage("invalid checker UUID");
+    createChecker(checkerCreation);
+  }
+
+  @Test
+  public void specifiedNameIsRespectedForNewChecker() throws Exception {
+    CheckerCreation checkerCreation =
+        getPrefilledCheckerCreationBuilder().setName(checkerName).build();
+    createChecker(checkerCreation);
+
+    CheckerConfig checkerConfig = loadChecker(checkerCreation.getCheckerUuid());
+    assertThat(checkerConfig).hasName(checkerName);
+    assertThat(checkerConfig).configStringList("name").containsExactly(checkerName);
+  }
+
+  @Test
+  public void nameOfCheckerUpdateOverridesCheckerCreation() throws Exception {
+    String anotherName = "another-name";
+
+    CheckerCreation checkerCreation =
+        getPrefilledCheckerCreationBuilder().setName(checkerName).build();
+    CheckerUpdate checkerUpdate = CheckerUpdate.builder().setName(anotherName).build();
+    createChecker(checkerCreation, checkerUpdate);
+
+    CheckerConfig checkerConfig = loadChecker(checkerCreation.getCheckerUuid());
+    assertThat(checkerConfig).hasName(anotherName);
+    assertThat(checkerConfig).configStringList("name").containsExactly(anotherName);
+  }
+
+  @Test
+  public void nameOfNewCheckerMustNotBeEmpty() throws Exception {
+    CheckerCreation checkerCreation = getPrefilledCheckerCreationBuilder().setName("").build();
+    CheckerConfig checkerConfig =
+        CheckerConfig.createForNewChecker(projectName, repository, checkerCreation);
+
+    try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
+      exception.expectCause(instanceOf(ConfigInvalidException.class));
+      exception.expectMessage(String.format("Name of the checker %s must be defined", checkerUuid));
+      checkerConfig.commit(metaDataUpdate);
+    }
+  }
+
+  @Test
+  public void descriptionDefaultsToOptionalEmpty() throws Exception {
+    CheckerCreation checkerCreation =
+        CheckerCreation.builder()
+            .setCheckerUuid(checkerUuid)
+            .setName(checkerName)
+            .setRepository(checkerRepository)
+            .build();
+    createChecker(checkerCreation);
+
+    CheckerConfig checkerConfig = loadChecker(checkerCreation.getCheckerUuid());
+    assertThat(checkerConfig).hasDescriptionThat().isEmpty();
+    assertThat(checkerConfig).configStringList("description").isEmpty();
+  }
+
+  @Test
+  public void specifiedDescriptionIsRespectedForNewChecker() throws Exception {
+    String description = "This is a test checker.";
+
+    CheckerCreation checkerCreation = getPrefilledCheckerCreationBuilder().build();
+    CheckerUpdate checkerUpdate = CheckerUpdate.builder().setDescription(description).build();
+    createChecker(checkerCreation, checkerUpdate);
+
+    CheckerConfig checkerConfig = loadChecker(checkerCreation.getCheckerUuid());
+    assertThat(checkerConfig).hasDescriptionThat().value().isEqualTo(description);
+    assertThat(checkerConfig).configStringList("description").containsExactly(description);
+  }
+
+  @Test
+  public void emptyDescriptionForNewCheckerIsIgnored() throws Exception {
+    CheckerCreation checkerCreation = getPrefilledCheckerCreationBuilder().build();
+    CheckerUpdate checkerUpdate = CheckerUpdate.builder().setDescription("").build();
+    createChecker(checkerCreation, checkerUpdate);
+
+    CheckerConfig checkerConfig = loadChecker(checkerCreation.getCheckerUuid());
+    assertThat(checkerConfig).hasDescriptionThat().isEmpty();
+    assertThat(checkerConfig).configStringList("description").isEmpty();
+  }
+
+  @Test
+  public void urlDefaultsToOptionalEmpty() throws Exception {
+    CheckerCreation checkerCreation =
+        CheckerCreation.builder()
+            .setCheckerUuid(checkerUuid)
+            .setName(checkerName)
+            .setRepository(checkerRepository)
+            .build();
+    createChecker(checkerCreation);
+
+    CheckerConfig checkerConfig = loadChecker(checkerCreation.getCheckerUuid());
+    assertThat(checkerConfig).hasUrlThat().isEmpty();
+    assertThat(checkerConfig).configStringList("url").isEmpty();
+  }
+
+  @Test
+  public void specifiedUrlIsRespectedForNewChecker() throws Exception {
+    String url = "http://example.com/my-checker";
+
+    CheckerCreation checkerCreation = getPrefilledCheckerCreationBuilder().build();
+    CheckerUpdate checkerUpdate = CheckerUpdate.builder().setUrl(url).build();
+    createChecker(checkerCreation, checkerUpdate);
+
+    CheckerConfig checkerConfig = loadChecker(checkerCreation.getCheckerUuid());
+    assertThat(checkerConfig).hasUrlThat().value().isEqualTo(url);
+    assertThat(checkerConfig).configStringList("url").containsExactly(url);
+  }
+
+  @Test
+  public void emptyUrlForNewCheckerIsIgnored() throws Exception {
+    CheckerCreation checkerCreation = getPrefilledCheckerCreationBuilder().build();
+    CheckerUpdate checkerUpdate = CheckerUpdate.builder().setUrl("").build();
+    createChecker(checkerCreation, checkerUpdate);
+
+    CheckerConfig checkerConfig = loadChecker(checkerCreation.getCheckerUuid());
+    assertThat(checkerConfig).hasUrlThat().isEmpty();
+    assertThat(checkerConfig).configStringList("url").isEmpty();
+  }
+
+  @Test
+  public void specifiedRepositoryIsRespectedForNewChecker() throws Exception {
+    CheckerCreation checkerCreation =
+        getPrefilledCheckerCreationBuilder().setRepository(checkerRepository).build();
+    createChecker(checkerCreation);
+
+    CheckerConfig checkerConfig = loadChecker(checkerCreation.getCheckerUuid());
+    assertThat(checkerConfig).hasRepository(checkerRepository);
+    assertThat(checkerConfig)
+        .configStringList("repository")
+        .containsExactly(checkerRepository.get());
+  }
+
+  @Test
+  public void repositoryOfCheckerUpdateOverridesCheckerCreation() throws Exception {
+    Project.NameKey anotherRepository = new Project.NameKey("another-repo");
+
+    CheckerCreation checkerCreation =
+        getPrefilledCheckerCreationBuilder().setRepository(checkerRepository).build();
+    CheckerUpdate checkerUpdate = CheckerUpdate.builder().setRepository(anotherRepository).build();
+    createChecker(checkerCreation, checkerUpdate);
+
+    CheckerConfig checkerConfig = loadChecker(checkerCreation.getCheckerUuid());
+    assertThat(checkerConfig).hasRepository(anotherRepository);
+    assertThat(checkerConfig)
+        .configStringList("repository")
+        .containsExactly(anotherRepository.get());
+  }
+
+  @Test
+  public void repositoryOfNewCheckerMustNotBeEmpty() throws Exception {
+    CheckerCreation checkerCreation =
+        getPrefilledCheckerCreationBuilder().setRepository(new Project.NameKey("")).build();
+    CheckerConfig checkerConfig =
+        CheckerConfig.createForNewChecker(projectName, repository, checkerCreation);
+
+    try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
+      exception.expectCause(instanceOf(ConfigInvalidException.class));
+      exception.expectMessage(
+          String.format("Repository of the checker %s must be defined", checkerUuid));
+      checkerConfig.commit(metaDataUpdate);
+    }
+  }
+
+  @Test
+  public void createdOnDefaultsToNow() throws Exception {
+    // Git timestamps are only precise to the second.
+    Timestamp testStart = TimeUtil.truncateToSecond(TimeUtil.nowTs());
+
+    createArbitraryChecker(checkerUuid);
+    CheckerConfig checkerConfig = loadChecker(checkerUuid);
+    assertThat(checkerConfig).hasCreatedOnThat().isAtLeast(testStart);
+  }
+
+  @Test
+  public void specifiedCreatedOnIsRespectedForNewChecker() throws Exception {
+    Timestamp createdOn = toTimestamp(LocalDate.of(2017, Month.DECEMBER, 11).atTime(13, 44, 10));
+
+    CheckerCreation checkerCreation = getPrefilledCheckerCreationBuilder().build();
+    CheckerUpdate checkerUpdate = CheckerUpdate.builder().setUpdatedOn(createdOn).build();
+    createChecker(checkerCreation, checkerUpdate);
+
+    CheckerConfig checkerConfig = loadChecker(checkerCreation.getCheckerUuid());
+    assertThat(checkerConfig).hasCreatedOnThat().isEqualTo(createdOn);
+  }
+
+  @Test
+  public void nameInConfigMayNotBeUndefined() throws Exception {
+    populateCheckerConfig(checkerUuid, "[checker]");
+
+    exception.expect(ConfigInvalidException.class);
+    exception.expectMessage(String.format("name of checker %s not set", checkerUuid));
+    loadChecker(checkerUuid);
+  }
+
+  @Test
+  public void correctCommitMessageForCheckerUpdate() throws Exception {
+    createArbitraryChecker(checkerUuid);
+    assertThatCommitMessage(checkerUuid).isEqualTo("Create checker");
+
+    CheckerUpdate checkerUpdate = CheckerUpdate.builder().setDescription("A description.").build();
+    updateChecker(checkerUuid, checkerUpdate);
+    assertThatCommitMessage(checkerUuid).isEqualTo("Update checker");
+  }
+
+  @Test
+  public void nameCanBeUpdated() throws Exception {
+    CheckerCreation checkerCreation =
+        CheckerCreation.builder()
+            .setCheckerUuid(checkerUuid)
+            .setName(checkerName)
+            .setRepository(checkerRepository)
+            .build();
+    createChecker(checkerCreation);
+
+    String newName = "new-name";
+    CheckerUpdate checkerUpdate = CheckerUpdate.builder().setName(newName).build();
+    updateChecker(checkerUuid, checkerUpdate);
+
+    CheckerConfig checkerConfig = loadChecker(checkerUuid);
+    assertThat(checkerConfig).hasName(newName);
+    assertThat(checkerConfig).configStringList("name").containsExactly(newName);
+
+    assertThatCommitMessage(checkerUuid)
+        .isEqualTo("Update checker\n\nRename from " + checkerName + " to " + newName);
+  }
+
+  @Test
+  public void nameCannotBeRemoved() throws Exception {
+    createArbitraryChecker(checkerUuid);
+
+    CheckerUpdate checkerUpdate = CheckerUpdate.builder().setName("").build();
+
+    exception.expect(IOException.class);
+    exception.expectMessage(String.format("Name of the checker %s must be defined", checkerUuid));
+    updateChecker(checkerUuid, checkerUpdate);
+  }
+
+  @Test
+  public void descriptionCanBeUpdated() throws Exception {
+    createArbitraryChecker(checkerUuid);
+    String newDescription = "New description";
+
+    CheckerUpdate checkerUpdate = CheckerUpdate.builder().setDescription(newDescription).build();
+    updateChecker(checkerUuid, checkerUpdate);
+
+    CheckerConfig checkerConfig = loadChecker(checkerUuid);
+    assertThat(checkerConfig).hasDescriptionThat().value().isEqualTo(newDescription);
+    assertThat(checkerConfig).configStringList("description").containsExactly(newDescription);
+  }
+
+  @Test
+  public void descriptionCanBeRemoved() throws Exception {
+    createArbitraryChecker(checkerUuid);
+
+    CheckerUpdate checkerUpdate = CheckerUpdate.builder().setDescription("").build();
+    CheckerConfig checkerConfig = updateChecker(checkerUuid, checkerUpdate);
+    assertThat(checkerConfig).hasDescriptionThat().isEmpty();
+    assertThat(checkerConfig).configStringList("description").isEmpty();
+  }
+
+  @Test
+  public void urlCanBeUpdated() throws Exception {
+    createArbitraryChecker(checkerUuid);
+    String newUrl = "http://example.com/my-checker";
+
+    CheckerUpdate checkerUpdate = CheckerUpdate.builder().setUrl(newUrl).build();
+    updateChecker(checkerUuid, checkerUpdate);
+
+    CheckerConfig checkerConfig = loadChecker(checkerUuid);
+    assertThat(checkerConfig).hasUrlThat().value().isEqualTo(newUrl);
+    assertThat(checkerConfig).configStringList("url").containsExactly(newUrl);
+  }
+
+  @Test
+  public void urlCanBeRemoved() throws Exception {
+    createArbitraryChecker(checkerUuid);
+
+    CheckerUpdate checkerUpdate = CheckerUpdate.builder().setUrl("").build();
+    CheckerConfig checkerConfig = updateChecker(checkerUuid, checkerUpdate);
+    assertThat(checkerConfig).hasUrlThat().isEmpty();
+    assertThat(checkerConfig).configStringList("url").isEmpty();
+  }
+
+  @Test
+  public void repositoryCanBeUpdated() throws Exception {
+    CheckerCreation checkerCreation =
+        CheckerCreation.builder()
+            .setCheckerUuid(checkerUuid)
+            .setName(checkerName)
+            .setRepository(checkerRepository)
+            .build();
+    createChecker(checkerCreation);
+
+    Project.NameKey newRepository = new Project.NameKey("another-repo");
+    CheckerUpdate checkerUpdate = CheckerUpdate.builder().setRepository(newRepository).build();
+    updateChecker(checkerUuid, checkerUpdate);
+
+    CheckerConfig checkerConfig = loadChecker(checkerUuid);
+    assertThat(checkerConfig).hasRepository(newRepository);
+    assertThat(checkerConfig).configStringList("repository").containsExactly(newRepository.get());
+
+    assertThatCommitMessage(checkerUuid).isEqualTo("Update checker");
+  }
+
+  @Test
+  public void repositoryCannotBeRemoved() throws Exception {
+    createArbitraryChecker(checkerUuid);
+
+    CheckerUpdate checkerUpdate =
+        CheckerUpdate.builder().setRepository(new Project.NameKey("")).build();
+
+    exception.expect(IOException.class);
+    exception.expectMessage(
+        String.format("Repository of the checker %s must be defined", checkerUuid));
+    updateChecker(checkerUuid, checkerUpdate);
+  }
+
+  @Test
+  public void createDisabledChecker() throws Exception {
+    CheckerCreation checkerCreation = getPrefilledCheckerCreationBuilder().build();
+    CheckerUpdate checkerUpdate = CheckerUpdate.builder().setStatus(CheckerStatus.DISABLED).build();
+    CheckerConfig checker = createChecker(checkerCreation, checkerUpdate);
+
+    assertThat(checker).hasStatus(CheckerStatus.DISABLED);
+    assertThat(checker).configStringList("status").containsExactly("disabled");
+    assertThatCommitMessage(checkerUuid).isEqualTo("Create checker");
+  }
+
+  @Test
+  public void updateStatusToSameStatus() throws Exception {
+    CheckerConfig checker = createArbitraryChecker(checkerUuid);
+    assertThat(checker).hasStatus(CheckerStatus.ENABLED);
+    assertThat(checker).configStringList("status").containsExactly("enabled");
+
+    CheckerUpdate checkerUpdate = CheckerUpdate.builder().setStatus(CheckerStatus.ENABLED).build();
+    checker = updateChecker(checkerUuid, checkerUpdate);
+    assertThat(checker).hasStatus(CheckerStatus.ENABLED);
+    assertThat(checker).configStringList("status").containsExactly("enabled");
+  }
+
+  @Test
+  public void disableAndReenable() throws Exception {
+    createArbitraryChecker(checkerUuid);
+
+    CheckerUpdate checkerUpdate = CheckerUpdate.builder().setStatus(CheckerStatus.DISABLED).build();
+    CheckerConfig checker = updateChecker(checkerUuid, checkerUpdate);
+    assertThat(checker).hasStatus(CheckerStatus.DISABLED);
+    assertThat(checker).configStringList("status").containsExactly("disabled");
+
+    checkerUpdate = CheckerUpdate.builder().setStatus(CheckerStatus.ENABLED).build();
+    checker = updateChecker(checkerUuid, checkerUpdate);
+    assertThat(checker).hasStatus(CheckerStatus.ENABLED);
+    assertThat(checker).configStringList("status").containsExactly("enabled");
+  }
+
+  @Test
+  public void refStateIsCorrectlySet() throws Exception {
+    CheckerCreation checkerCreation =
+        getPrefilledCheckerCreationBuilder().setCheckerUuid(checkerUuid).build();
+    CheckerConfig newChecker = createChecker(checkerCreation);
+    ObjectId expectedRefStateAfterCreation = getCheckerRefState(checkerUuid);
+    assertThat(newChecker).hasRefStateThat().isEqualTo(expectedRefStateAfterCreation);
+
+    CheckerConfig checker = loadChecker(checkerUuid);
+    assertThat(checker).hasRefStateThat().isEqualTo(expectedRefStateAfterCreation);
+
+    CheckerUpdate checkerUpdate = CheckerUpdate.builder().setDescription("A description.").build();
+    CheckerConfig updatedChecker = updateChecker(checkerUuid, checkerUpdate);
+    ObjectId expectedRefStateAfterUpdate = getCheckerRefState(checkerUuid);
+    assertThat(expectedRefStateAfterUpdate).isNotEqualTo(expectedRefStateAfterCreation);
+    assertThat(updatedChecker).hasRefStateThat().isEqualTo(expectedRefStateAfterUpdate);
+  }
+
+  private CheckerConfig createArbitraryChecker(String checkerUuid) throws Exception {
+    CheckerCreation checkerCreation =
+        getPrefilledCheckerCreationBuilder().setCheckerUuid(checkerUuid).build();
+    return createChecker(checkerCreation);
+  }
+
+  private CheckerCreation.Builder getPrefilledCheckerCreationBuilder() {
+    return CheckerCreation.builder()
+        .setCheckerUuid(checkerUuid)
+        .setName(checkerName)
+        .setRepository(checkerRepository);
+  }
+
+  private CheckerConfig createChecker(CheckerCreation checkerCreation) throws Exception {
+    CheckerConfig checkerConfig =
+        CheckerConfig.createForNewChecker(projectName, repository, checkerCreation);
+    commit(checkerConfig);
+    return loadChecker(checkerCreation.getCheckerUuid());
+  }
+
+  private CheckerConfig createChecker(CheckerCreation checkerCreation, CheckerUpdate checkerUpdate)
+      throws Exception {
+    CheckerConfig checkerConfig =
+        CheckerConfig.createForNewChecker(projectName, repository, checkerCreation);
+    checkerConfig.setCheckerUpdate(checkerUpdate);
+    commit(checkerConfig);
+    return loadChecker(checkerCreation.getCheckerUuid());
+  }
+
+  private CheckerConfig updateChecker(String checkerUuid, CheckerUpdate checkerUpdate)
+      throws Exception {
+    CheckerConfig checkerConfig =
+        CheckerConfig.loadForChecker(projectName, repository, checkerUuid);
+    checkerConfig.setCheckerUpdate(checkerUpdate);
+    commit(checkerConfig);
+    return loadChecker(checkerUuid);
+  }
+
+  private CheckerConfig loadChecker(String uuid) throws Exception {
+    return CheckerConfig.loadForChecker(projectName, repository, uuid);
+  }
+
+  private void commit(CheckerConfig checkerConfig) throws IOException {
+    try (MetaDataUpdate metaDataUpdate = createMetaDataUpdate()) {
+      checkerConfig.commit(metaDataUpdate);
+    }
+  }
+
+  private MetaDataUpdate createMetaDataUpdate() {
+    PersonIdent serverIdent =
+        new PersonIdent(
+            "Gerrit Server", "noreply@gerritcodereview.com", TimeUtil.nowTs(), timeZone);
+
+    MetaDataUpdate metaDataUpdate =
+        new MetaDataUpdate(
+            GitReferenceUpdated.DISABLED, new Project.NameKey("Test Repository"), repository);
+    metaDataUpdate.getCommitBuilder().setCommitter(serverIdent);
+    metaDataUpdate.getCommitBuilder().setAuthor(serverIdent);
+    return metaDataUpdate;
+  }
+
+  private void populateCheckerConfig(String uuid, String fileContent) throws Exception {
+    testRepository
+        .branch(CheckerRef.refsCheckers(uuid))
+        .commit()
+        .message("Prepopulate checker.config")
+        .add(CheckerConfig.CHECKER_CONFIG_FILE, fileContent)
+        .create();
+  }
+
+  private ObjectId getCheckerRefState(String checkerUuid) throws IOException {
+    return repository.exactRef(CheckerRef.refsCheckers(checkerUuid)).getObjectId();
+  }
+
+  private StringSubject assertThatCommitMessage(String checkerUuid) throws IOException {
+    try (RevWalk rw = new RevWalk(repository)) {
+      RevCommit commit = rw.parseCommit(getCheckerRefState(checkerUuid));
+      return assertThat(commit.getFullMessage()).named("commit message");
+    }
+  }
+
+  private static Timestamp toTimestamp(LocalDateTime localDateTime) {
+    return Timestamp.from(localDateTime.atZone(ZoneId.systemDefault()).toInstant());
+  }
+}