Merge branch 'stable-3.2' into stable-3.3

* stable-3.2:
  Initial implementation

Change-Id: I6c17ba68b539ce6dbaa9610798fe07c05968f449
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..659c9e1
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,18 @@
+# Compiled class file
+*.class
+
+# Log file
+*.log
+
+# IJ
+.idea
+.ijwb/
+
+# Package Files #
+*.jar
+*.war
+*.nar
+*.ear
+*.zip
+*.tar.gz
+*.rar
\ No newline at end of file
diff --git a/BUILD b/BUILD
new file mode 100644
index 0000000..7bd22d4
--- /dev/null
+++ b/BUILD
@@ -0,0 +1,50 @@
+load(
+    "//tools/bzl:plugin.bzl",
+    "PLUGIN_DEPS",
+    "PLUGIN_TEST_DEPS",
+    "gerrit_plugin",
+)
+load("//tools/bzl:junit.bzl", "junit_tests")
+
+gerrit_plugin(
+    name = "replication-status",
+    srcs = glob(["src/main/java/**/*.java"]),
+    manifest_entries = [
+        "Gerrit-PluginName: replication-status",
+        "Gerrit-Module: com.googlesource.gerrit.plugins.replicationstatus.Module",
+        "Implementation-Title: Replication Status",
+        "Implementation-URL: https://gerrit.googlesource.com/plugins/replication-status",
+    ],
+    resources = glob(["src/main/resources/**/*"]),
+    deps = [
+        ":replication-neverlink",
+        "//java/com/google/gerrit/proto",
+        "//plugins/replication-status/proto:replication_status_cache_java_proto",
+        "@error-prone-annotations//jar",
+    ],
+)
+
+junit_tests(
+    name = "replicationstatus_tests",
+    srcs = glob(["src/test/java/**/*.java"]),
+    resources = glob(["src/test/resources/**/*"]),
+    deps = [
+        ":replicationstatus__plugin_test_deps",
+    ],
+)
+
+java_library(
+    name = "replication-neverlink",
+    neverlink = 1,
+    exports = ["//plugins/replication"],
+)
+
+java_library(
+    name = "replicationstatus__plugin_test_deps",
+    testonly = 1,
+    visibility = ["//visibility:public"],
+    exports = PLUGIN_DEPS + PLUGIN_TEST_DEPS + [
+        ":replication-status__plugin",
+        "//plugins/replication",
+    ],
+)
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..261eeb9
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,201 @@
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   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.
diff --git a/README.md b/README.md
index 5f59fa5..a5c70e0 100644
--- a/README.md
+++ b/README.md
@@ -2,3 +2,85 @@
 
 Record and display the repository's replication status without having to dig
 into the Gerrit replication_log
+
+Consumes replication events and updates a cache with the latest replication
+status of specific refs to specific remotes.
+
+The cache information is then exposed via a project's resource REST endpoint:
+
+```bash
+curl -v --user <user> '<gerrit-server>/a/projects/<project-name>/remotes/<remote-url>/replication-status'
+```
+
+* <project-name>: an (url-encoded) project repository
+* <remote-url>: an (url-encoded) remote URL for the replication
+
+For instance, to assess the replication status of the project `some/project` to
+the
+`https://github.com/some/project.git` URL, the following endpoint should be
+called:
+
+```bash
+curl -v --user <user> '<gerrit-server>/a/projects/some%2Fproject/remotes/https%3A%2F%2Fgithub.com%2Fsome%2Fproject.git/replication-status'
+```
+
+A payload, similar to this may be returned:
+
+```
+{
+  "remotes": {
+    "https://github.com/some/project.git": {
+      "status": {
+        "refs/changes/01/1/meta": {
+          "status": "SUCCEEDED",
+          "when": 1626688830
+        },
+        "refs/changes/03/3/meta": {
+          "status": "SUCCEEDED",
+          "when": 1626688854
+        },
+        "refs/changes/03/3/1": {
+          "status": "SUCCEEDED",
+          "when": 1626688854
+        },
+        "refs/changes/02/2/1": {
+          "status": "SUCCEEDED",
+          "when": 1626688844
+        },
+        "refs/changes/02/2/meta": {
+          "status": "SUCCEEDED",
+          "when": 1626688844
+        },
+        "refs/changes/01/1/1": {
+          "status": "SUCCEEDED",
+          "when": 1626688830
+        }
+      }
+    }
+  },
+  "status": "OK",
+  "project": "some/project"
+}
+```
+
+### HTTP status
+
+The endpoint returns different HTTP response code depending on the result:
+
+* 200 OK - The endpoint was called successfully, and a payload returned
+* 404 Not Found - Project was not found
+* 500 Failure - An unexpected server error occurred
+* 403 Forbidden - The user has no permission to query the endpoint. Only
+  Administrators and project owners are allowed
+
+### Overall status
+
+The REST-API response shows a `status` field, which shows the overall
+replication-status of the projects for the specified remote.
+
+- `OK` - all the refs have successfully replicated
+- `FAILED` - Some refs have not replicated successfully
+
+### TODO
+
+* Does not consume pull-replication events.
diff --git a/proto/BUILD b/proto/BUILD
new file mode 100644
index 0000000..c8e0c74
--- /dev/null
+++ b/proto/BUILD
@@ -0,0 +1,13 @@
+load("@rules_java//java:defs.bzl", "java_proto_library")
+load("@rules_proto//proto:defs.bzl", "proto_library")
+
+proto_library(
+    name = "replication_status_cache_proto",
+    srcs = ["cache.proto"],
+)
+
+java_proto_library(
+    name = "replication_status_cache_java_proto",
+    visibility = ["//visibility:public"],
+    deps = [":replication_status_cache_proto"],
+)
diff --git a/proto/cache.proto b/proto/cache.proto
new file mode 100644
index 0000000..6206ac0
--- /dev/null
+++ b/proto/cache.proto
@@ -0,0 +1,36 @@
+// Copyright (C) 2021 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.
+
+syntax = "proto3";
+
+package replicationstatus.cache;
+
+option java_package = "com.googlesource.gerrit.plugins.replicationstatus.proto";
+
+// Serialized form of com.googlesource.gerrit.plugins.replicationstatus.ReplicationStatus.Key.
+// Next ID: 4
+message ReplicationStatusKeyProto {
+  string project = 1;
+  string remote = 2;
+  string ref = 3;
+}
+
+// Serialized form of com.googlesource.gerrit.plugins.replicationstatus.ReplicationStatus.
+// Next ID: 3
+message ReplicationStatusProto {
+  int64 when = 1;
+  string status = 2;
+}
+
+
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replicationstatus/EventHandler.java b/src/main/java/com/googlesource/gerrit/plugins/replicationstatus/EventHandler.java
new file mode 100644
index 0000000..580a85a
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replicationstatus/EventHandler.java
@@ -0,0 +1,64 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.googlesource.gerrit.plugins.replicationstatus;
+
+import static com.googlesource.gerrit.plugins.replicationstatus.ReplicationStatus.ReplicationStatusResult;
+
+import com.google.common.cache.Cache;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.server.config.GerritInstanceId;
+import com.google.gerrit.server.events.Event;
+import com.google.gerrit.server.events.EventListener;
+import com.google.inject.Inject;
+import com.google.inject.name.Named;
+import com.googlesource.gerrit.plugins.replication.RefReplicatedEvent;
+
+class EventHandler implements EventListener {
+  private final Cache<ReplicationStatus.Key, ReplicationStatus> replicationStatusCache;
+  private final String nodeInstanceId;
+
+  @Inject
+  EventHandler(
+      @Named(ReplicationStatus.CACHE_NAME)
+          Cache<ReplicationStatus.Key, ReplicationStatus> replicationStatusCache,
+      @Nullable @GerritInstanceId String nodeInstanceId) {
+    this.replicationStatusCache = replicationStatusCache;
+    this.nodeInstanceId = nodeInstanceId;
+  }
+
+  @Override
+  public void onEvent(Event event) {
+    if (shouldConsume(event)) {
+      if (event instanceof RefReplicatedEvent) {
+        RefReplicatedEvent replEvent = (RefReplicatedEvent) event;
+
+        ReplicationStatus.Key cacheKey =
+            ReplicationStatus.Key.create(
+                Project.nameKey(replEvent.project), replEvent.targetNode, replEvent.ref);
+
+        replicationStatusCache.put(
+            cacheKey,
+            ReplicationStatus.create(
+                ReplicationStatusResult.fromString(replEvent.status), replEvent.eventCreatedOn));
+      }
+    }
+  }
+
+  private boolean shouldConsume(Event event) {
+    return nodeInstanceId == null && event.instanceId == null
+        || nodeInstanceId != null && nodeInstanceId.equals(event.instanceId);
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replicationstatus/Module.java b/src/main/java/com/googlesource/gerrit/plugins/replicationstatus/Module.java
new file mode 100644
index 0000000..9109e55
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replicationstatus/Module.java
@@ -0,0 +1,28 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.googlesource.gerrit.plugins.replicationstatus;
+
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.server.events.EventListener;
+
+class Module extends LifecycleModule {
+  @Override
+  protected void configure() {
+    DynamicSet.bind(binder(), EventListener.class).to(EventHandler.class);
+    install(new ReplicationStatusApiModule());
+    install(new ReplicationStatusCacheModule());
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replicationstatus/ProjectReplicationStatus.java b/src/main/java/com/googlesource/gerrit/plugins/replicationstatus/ProjectReplicationStatus.java
new file mode 100644
index 0000000..a2c35eb
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replicationstatus/ProjectReplicationStatus.java
@@ -0,0 +1,39 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.googlesource.gerrit.plugins.replicationstatus;
+
+import com.google.auto.value.AutoValue;
+import java.util.Map;
+
+@AutoValue
+public abstract class ProjectReplicationStatus {
+  static ProjectReplicationStatus create(
+      Map<String, RemoteReplicationStatus> remotes,
+      ProjectReplicationStatusResult status,
+      String project) {
+    return new AutoValue_ProjectReplicationStatus(remotes, status, project);
+  }
+
+  public abstract Map<String, RemoteReplicationStatus> remotes();
+
+  public abstract ProjectReplicationStatusResult status();
+
+  public abstract String project();
+
+  enum ProjectReplicationStatusResult {
+    FAILED,
+    OK;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replicationstatus/RemoteReplicationStatus.java b/src/main/java/com/googlesource/gerrit/plugins/replicationstatus/RemoteReplicationStatus.java
new file mode 100644
index 0000000..f567e5e
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replicationstatus/RemoteReplicationStatus.java
@@ -0,0 +1,27 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.googlesource.gerrit.plugins.replicationstatus;
+
+import com.google.auto.value.AutoValue;
+import java.util.Map;
+
+@AutoValue
+public abstract class RemoteReplicationStatus {
+  static RemoteReplicationStatus create(Map<String, ReplicationStatus> status) {
+    return new AutoValue_RemoteReplicationStatus(status);
+  }
+
+  public abstract Map<String, ReplicationStatus> status();
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replicationstatus/ReplicationStatus.java b/src/main/java/com/googlesource/gerrit/plugins/replicationstatus/ReplicationStatus.java
new file mode 100644
index 0000000..438edc2
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replicationstatus/ReplicationStatus.java
@@ -0,0 +1,123 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.googlesource.gerrit.plugins.replicationstatus;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.proto.Protos;
+import com.google.gerrit.server.cache.serialize.CacheSerializer;
+import com.googlesource.gerrit.plugins.replicationstatus.proto.Cache;
+
+@AutoValue
+public abstract class ReplicationStatus {
+  static final String CACHE_NAME = "replication_status";
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  static ReplicationStatus create(ReplicationStatusResult status, long when) {
+    return new AutoValue_ReplicationStatus(status, when);
+  }
+
+  public abstract ReplicationStatusResult status();
+
+  public abstract long when();
+
+  public boolean isFailure() {
+    return status().isFailure();
+  }
+
+  @AutoValue
+  public abstract static class Key {
+    static ReplicationStatus.Key create(Project.NameKey projectName, String remote, String ref) {
+      return new AutoValue_ReplicationStatus_Key(projectName, remote, ref);
+    }
+
+    abstract Project.NameKey projectName();
+
+    abstract String remote();
+
+    abstract String ref();
+
+    enum Serializer implements CacheSerializer<ReplicationStatus.Key> {
+      INSTANCE;
+
+      @Override
+      public byte[] serialize(ReplicationStatus.Key object) {
+        return Protos.toByteArray(
+            Cache.ReplicationStatusKeyProto.newBuilder()
+                .setProject(object.projectName().get())
+                .setRemote(object.remote())
+                .setRef(object.ref())
+                .build());
+      }
+
+      @Override
+      public ReplicationStatus.Key deserialize(byte[] in) {
+        Cache.ReplicationStatusKeyProto proto =
+            Protos.parseUnchecked(Cache.ReplicationStatusKeyProto.parser(), in);
+        return ReplicationStatus.Key.create(
+            Project.nameKey(proto.getProject()), proto.getRemote(), proto.getRef());
+      }
+    }
+  }
+
+  enum Serializer implements CacheSerializer<ReplicationStatus> {
+    INSTANCE;
+
+    @Override
+    public byte[] serialize(ReplicationStatus object) {
+      return Protos.toByteArray(
+          Cache.ReplicationStatusProto.newBuilder()
+              .setWhen(object.when())
+              .setStatus(object.status().name())
+              .build());
+    }
+
+    @Override
+    public ReplicationStatus deserialize(byte[] in) {
+      Cache.ReplicationStatusProto proto =
+          Protos.parseUnchecked(Cache.ReplicationStatusProto.parser(), in);
+
+      return ReplicationStatus.create(
+          ReplicationStatus.ReplicationStatusResult.valueOf(proto.getStatus()), proto.getWhen());
+    }
+  }
+
+  enum ReplicationStatusResult {
+    FAILED,
+    NOT_ATTEMPTED,
+    SUCCEEDED,
+    UNKNOWN;
+
+    static ReplicationStatusResult fromString(String result) {
+      switch (result.toLowerCase()) {
+        case "succeeded":
+          return SUCCEEDED;
+        case "not_attempted":
+          return NOT_ATTEMPTED;
+        case "failed":
+          return FAILED;
+        default:
+          logger.atSevere().log(
+              "Could not parse result into a valid replication status: %", result);
+          return UNKNOWN;
+      }
+    }
+
+    public boolean isFailure() {
+      return this == FAILED || this == UNKNOWN;
+    }
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replicationstatus/ReplicationStatusAction.java b/src/main/java/com/googlesource/gerrit/plugins/replicationstatus/ReplicationStatusAction.java
new file mode 100644
index 0000000..7691897
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replicationstatus/ReplicationStatusAction.java
@@ -0,0 +1,105 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.googlesource.gerrit.plugins.replicationstatus;
+
+import static com.googlesource.gerrit.plugins.replicationstatus.ReplicationStatus.CACHE_NAME;
+
+import com.google.common.cache.Cache;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.inject.Inject;
+import com.google.inject.name.Named;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+
+class ReplicationStatusAction implements RestReadView<ReplicationStatusProjectRemoteResource> {
+  private final PermissionBackend permissionBackend;
+  private final GitRepositoryManager repoManager;
+  private final Cache<ReplicationStatus.Key, ReplicationStatus> replicationStatusCache;
+
+  @Inject
+  ReplicationStatusAction(
+      PermissionBackend permissionBackend,
+      GitRepositoryManager repoManager,
+      @Named(CACHE_NAME) Cache<ReplicationStatus.Key, ReplicationStatus> replicationStatusCache) {
+    this.permissionBackend = permissionBackend;
+    this.repoManager = repoManager;
+    this.replicationStatusCache = replicationStatusCache;
+  }
+
+  @Override
+  public Response<ProjectReplicationStatus> apply(ReplicationStatusProjectRemoteResource resource)
+      throws AuthException, PermissionBackendException, BadRequestException,
+          ResourceConflictException, IOException {
+
+    Project.NameKey projectNameKey = resource.getProjectNameKey();
+    String remoteURL = resource.getRemoteUrl();
+
+    checkIsOwnerOrAdmin(projectNameKey);
+
+    ProjectReplicationStatus.ProjectReplicationStatusResult overallStatus =
+        ProjectReplicationStatus.ProjectReplicationStatusResult.OK;
+    Map<String, RemoteReplicationStatus> remoteStatuses = new HashMap<>();
+    try (Repository git = repoManager.openRepository(projectNameKey)) {
+
+      Map<String, ReplicationStatus> refStatuses = new HashMap<>();
+      for (Ref r : git.getRefDatabase().getRefs()) {
+        ReplicationStatus replicationStatus =
+            replicationStatusCache.getIfPresent(
+                ReplicationStatus.Key.create(projectNameKey, remoteURL, r.getName()));
+
+        if (replicationStatus != null) {
+          refStatuses.put(r.getName(), replicationStatus);
+          if (replicationStatus.isFailure()) {
+            overallStatus = ProjectReplicationStatus.ProjectReplicationStatusResult.FAILED;
+          }
+        }
+      }
+      remoteStatuses.put(remoteURL, RemoteReplicationStatus.create(refStatuses));
+
+      ProjectReplicationStatus projectStatus =
+          ProjectReplicationStatus.create(remoteStatuses, overallStatus, projectNameKey.get());
+
+      return Response.ok(projectStatus);
+
+    } catch (RepositoryNotFoundException e) {
+      throw new BadRequestException(
+          String.format("Project %s does not exist", projectNameKey.get()));
+    }
+  }
+
+  private void checkIsOwnerOrAdmin(Project.NameKey project) throws AuthException {
+    if (!permissionBackend.currentUser().testOrFalse(GlobalPermission.ADMINISTRATE_SERVER)
+        && !permissionBackend
+            .currentUser()
+            .project(project)
+            .testOrFalse(ProjectPermission.WRITE_CONFIG)) {
+      throw new AuthException("Administrate Server or Project owner required");
+    }
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replicationstatus/ReplicationStatusApiModule.java b/src/main/java/com/googlesource/gerrit/plugins/replicationstatus/ReplicationStatusApiModule.java
new file mode 100644
index 0000000..adecc6d
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replicationstatus/ReplicationStatusApiModule.java
@@ -0,0 +1,33 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.googlesource.gerrit.plugins.replicationstatus;
+
+import static com.google.gerrit.server.project.ProjectResource.PROJECT_KIND;
+import static com.googlesource.gerrit.plugins.replicationstatus.ReplicationStatusProjectRemoteResource.REPLICATION_STATUS_PROJECT_REMOTE_KIND;
+
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.RestApiModule;
+import com.google.inject.Scopes;
+
+class ReplicationStatusApiModule extends RestApiModule {
+  @Override
+  protected void configure() {
+    bind(ReplicationStatusAction.class).in(Scopes.SINGLETON);
+    DynamicMap.mapOf(binder(), REPLICATION_STATUS_PROJECT_REMOTE_KIND);
+    child(PROJECT_KIND, "remotes").to(ReplicationStatusProjectRemoteCollection.class);
+    get(REPLICATION_STATUS_PROJECT_REMOTE_KIND, "replication-status")
+        .to(ReplicationStatusAction.class);
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replicationstatus/ReplicationStatusCacheModule.java b/src/main/java/com/googlesource/gerrit/plugins/replicationstatus/ReplicationStatusCacheModule.java
new file mode 100644
index 0000000..fa5a472
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replicationstatus/ReplicationStatusCacheModule.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.googlesource.gerrit.plugins.replicationstatus;
+
+import com.google.gerrit.server.cache.CacheModule;
+
+public class ReplicationStatusCacheModule extends CacheModule {
+
+  @Override
+  protected void configure() {
+    persist(ReplicationStatus.CACHE_NAME, ReplicationStatus.Key.class, ReplicationStatus.class)
+        .version(1)
+        .diskLimit(-1)
+        .keySerializer(ReplicationStatus.Key.Serializer.INSTANCE)
+        .valueSerializer(ReplicationStatus.Serializer.INSTANCE);
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replicationstatus/ReplicationStatusProjectRemoteCollection.java b/src/main/java/com/googlesource/gerrit/plugins/replicationstatus/ReplicationStatusProjectRemoteCollection.java
new file mode 100644
index 0000000..a8c0b0c
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replicationstatus/ReplicationStatusProjectRemoteCollection.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.googlesource.gerrit.plugins.replicationstatus;
+
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.NotImplementedException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+@Singleton
+public class ReplicationStatusProjectRemoteCollection
+    implements ChildCollection<ProjectResource, ReplicationStatusProjectRemoteResource> {
+  private final DynamicMap<RestView<ReplicationStatusProjectRemoteResource>> views;
+
+  @Inject
+  ReplicationStatusProjectRemoteCollection(
+      DynamicMap<RestView<ReplicationStatusProjectRemoteResource>> views) {
+    this.views = views;
+  }
+
+  @Override
+  public RestView<ProjectResource> list() throws RestApiException {
+    throw new NotImplementedException();
+  }
+
+  @Override
+  public ReplicationStatusProjectRemoteResource parse(ProjectResource parent, IdString id)
+      throws ResourceNotFoundException, Exception {
+    Project.NameKey projectNameKey = parent.getNameKey();
+    String remoteURL = id.get();
+
+    return new ReplicationStatusProjectRemoteResource(projectNameKey, remoteURL, remoteURL);
+  }
+
+  @Override
+  public DynamicMap<RestView<ReplicationStatusProjectRemoteResource>> views() {
+    return views;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replicationstatus/ReplicationStatusProjectRemoteResource.java b/src/main/java/com/googlesource/gerrit/plugins/replicationstatus/ReplicationStatusProjectRemoteResource.java
new file mode 100644
index 0000000..77b81cd
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replicationstatus/ReplicationStatusProjectRemoteResource.java
@@ -0,0 +1,49 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.googlesource.gerrit.plugins.replicationstatus;
+
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.restapi.RestResource;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.inject.TypeLiteral;
+
+public class ReplicationStatusProjectRemoteResource implements RestResource {
+  public static final TypeLiteral<RestView<ReplicationStatusProjectRemoteResource>>
+      REPLICATION_STATUS_PROJECT_REMOTE_KIND =
+          new TypeLiteral<RestView<ReplicationStatusProjectRemoteResource>>() {};
+
+  private final Project.NameKey projectNameKey;
+  private final String remote;
+  private final String remoteURL;
+
+  public ReplicationStatusProjectRemoteResource(
+      Project.NameKey projectNameKey, String remote, String remoteURL) {
+    this.projectNameKey = projectNameKey;
+    this.remote = remote;
+    this.remoteURL = remoteURL;
+  }
+
+  public Project.NameKey getProjectNameKey() {
+    return projectNameKey;
+  }
+
+  public String getRemote() {
+    return remote;
+  }
+
+  public String getRemoteUrl() {
+    return remoteURL;
+  }
+}
diff --git a/src/main/resources/Documentation/build.md b/src/main/resources/Documentation/build.md
new file mode 100644
index 0000000..9d5193b
--- /dev/null
+++ b/src/main/resources/Documentation/build.md
@@ -0,0 +1,33 @@
+# Build
+
+This plugin is built with Bazel in-tree build.
+
+## Build in Gerrit tree
+
+Create a symbolic link of the repository source to the Gerrit source tree
+/plugins/replication-status directory.
+
+Example:
+
+```shell
+git clone https://gerrit.googlesource.com/gerrit
+git clone https://gerrit.googlesource.com/plugins/replication-status
+cd gerrit/plugins ln -s ../../replication-status replication-status
+```
+
+From the Gerrit source tree issue the command
+
+```shell
+bazelisk build plugins/replication-status
+```
+
+The jar file is created
+under `basel-bin/plugins/replication-status/replication-status.jar`
+
+To execute the tests run
+
+```shell
+bazelisk test plugins/replication-status/...
+```
+
+from the Gerrit source tree.
\ No newline at end of file
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md
new file mode 100644
index 0000000..aa01313
--- /dev/null
+++ b/src/main/resources/Documentation/config.md
@@ -0,0 +1,45 @@
+# Config
+
+The plugin itself has no specific configuration, however some Gerrit specific
+settings are relevant.
+
+## Cache
+
+This plugin relies on a cache to store replication status information, the
+global cache configuration settings apply.
+
+Please look at
+the [gerrit cache documentation](https://gerrit-review.googlesource.com/Documentation/config-gerrit.html#cache)
+for more information on this.
+
+In particular:
+
+* To define the lifespan of replication-status entries in the cache, look at
+  the [maxAge](https://gerrit-review.googlesource.com/Documentation/config-gerrit.html#cache.name.maxAge)
+  documentation. *Default*: store forever with no expire
+
+* To define how much disk space the replication-status cache can take, look at
+  the [diskLimit](https://gerrit-review.googlesource.com/Documentation/config-gerrit.html#cache.name.diskLimit)
+  documentation. *Default*: disk storage for the cache is disabled
+
+To modify this cache behaviour add the following stanza to the `gerrit.config`,
+for example:
+
+```
+[cache "replication-status.replication_status"]
+   diskLimit = 52428800 # 50 Mb
+   maxAge = 1 day
+```
+
+## Gerrit instanceId
+
+This plugin will try to discriminate among events produced by the current
+instances versus events produced by different instances.
+
+If the gerrit node running this plugin has
+the [gerrit.instanceId]([gerrit.instanceId](https://gerrit-review.googlesource.com/Documentation/config-gerrit.html#gerrit.instanceId))
+set to a specific value, it will only consume events that match it.
+
+On the other hand, if the `gerrit.instanceId` is not defined, only events that _
+do not_ have instanceId set will be consumed, as events exhibiting different
+instanceId values must have been generated by different gerrit nodes.
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replicationstatus/ReplicationStatusIT.java b/src/test/java/com/googlesource/gerrit/plugins/replicationstatus/ReplicationStatusIT.java
new file mode 100644
index 0000000..493fdbd
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/replicationstatus/ReplicationStatusIT.java
@@ -0,0 +1,290 @@
+// Copyright (C) 2021 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.googlesource.gerrit.plugins.replicationstatus;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.acceptance.testsuite.project.TestProjectUpdate.allow;
+import static com.google.gerrit.extensions.restapi.Url.encode;
+import static com.googlesource.gerrit.plugins.replication.ReplicationState.RefPushResult;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.LightweightPluginDaemonTest;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.TestPlugin;
+import com.google.gerrit.acceptance.config.GerritConfig;
+import com.google.gerrit.acceptance.testsuite.project.ProjectOperations;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.entities.AccessSection;
+import com.google.gerrit.entities.AccountGroup;
+import com.google.gerrit.entities.Permission;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.api.groups.GroupInput;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.httpd.restapi.RestApiServlet;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gson.Gson;
+import com.google.inject.Inject;
+import com.googlesource.gerrit.plugins.replication.RefReplicatedEvent;
+import java.io.IOException;
+import java.net.URISyntaxException;
+import java.util.Collections;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.transport.RemoteRefUpdate;
+import org.junit.Before;
+import org.junit.Test;
+
+@TestPlugin(
+    name = "replication-status",
+    sysModule = "com.googlesource.gerrit.plugins.replicationstatus.Module")
+public class ReplicationStatusIT extends LightweightPluginDaemonTest {
+  private static final String REF_MASTER = Constants.R_HEADS + Constants.MASTER;
+  private static final String REMOTE = "ssh://some.remote.host";
+
+  private static final Gson gson = newGson();
+
+  @Inject protected SitePaths sitePaths;
+  @Inject private ProjectOperations projectOperations;
+
+  private EventHandler eventHandler;
+
+  @Before
+  public void setUp() throws IOException {
+    eventHandler = plugin.getSysInjector().getInstance(EventHandler.class);
+  }
+
+  @Test
+  @GerritConfig(name = "gerrit.instanceId", value = "testInstanceId-1")
+  public void shouldBeOKSuccessForAdminUsers() throws Exception {
+    RestResponse result = adminRestSession.get(endpoint(project, REMOTE));
+    result.assertOK();
+
+    assertThat(contentWithoutMagicJson(result)).isEqualTo(emptyReplicationStatus(project, REMOTE));
+  }
+
+  @Test
+  @GerritConfig(name = "gerrit.instanceId", value = "testInstanceId-1")
+  public void shouldBeOKSuccessForProjectOwners() throws Exception {
+    makeProjectOwner(user, project);
+    RestResponse result = userRestSession.get(endpoint(project, REMOTE));
+    result.assertOK();
+
+    assertThat(contentWithoutMagicJson(result)).isEqualTo(emptyReplicationStatus(project, REMOTE));
+  }
+
+  @Test
+  @GerritConfig(name = "gerrit.instanceId", value = "testInstanceId-1")
+  public void shouldBeForbiddenForNonProjectOwners() throws Exception {
+    RestResponse result = userRestSession.get(endpoint(project, REMOTE));
+    result.assertForbidden();
+
+    assertThat(result.getEntityContent()).contains("Administrate Server or Project owner required");
+  }
+
+  @Test
+  @GerritConfig(name = "gerrit.instanceId", value = "testInstanceId-1")
+  public void shouldBeForbiddenForAnonymousUsers() throws Exception {
+    RestResponse result = anonymousRestSession.get(endpoint(project, REMOTE));
+    result.assertForbidden();
+
+    assertThat(result.getEntityContent()).contains("Administrate Server or Project owner required");
+  }
+
+  @Test
+  @GerritConfig(name = "gerrit.instanceId", value = "testInstanceId-1")
+  public void shouldNotReportStatusOfReplicationsGeneratedOnDifferentNodes() throws Exception {
+    eventHandler.onEvent(
+        successReplicatedEvent("testInstanceId-2", System.currentTimeMillis(), REMOTE));
+
+    RestResponse result = adminRestSession.get(endpoint(project, REMOTE));
+    result.assertOK();
+
+    assertThat(contentWithoutMagicJson(result)).isEqualTo(emptyReplicationStatus(project, REMOTE));
+  }
+
+  @Test
+  @GerritConfig(name = "gerrit.instanceId", value = "testInstanceId-1")
+  public void shouldReturnSuccessfulProjectReplicationStatus() throws Exception {
+    long eventCreatedOn = System.currentTimeMillis();
+
+    eventHandler.onEvent(successReplicatedEvent("testInstanceId-1", eventCreatedOn, REMOTE));
+    RestResponse result = adminRestSession.get(endpoint(project, REMOTE));
+
+    result.assertOK();
+    assertThat(contentWithoutMagicJson(result))
+        .isEqualTo(successReplicationStatus(REMOTE, project, eventCreatedOn));
+  }
+
+  @Test
+  public void shouldConsumeEventsThatHaveNoInstanceId() throws Exception {
+    long eventCreatedOn = System.currentTimeMillis();
+
+    eventHandler.onEvent(successReplicatedEvent(null, eventCreatedOn, REMOTE));
+    RestResponse result = adminRestSession.get(endpoint(project, REMOTE));
+
+    result.assertOK();
+    assertThat(contentWithoutMagicJson(result))
+        .isEqualTo(successReplicationStatus(REMOTE, project, eventCreatedOn));
+  }
+
+  @Test
+  public void shouldNotConsumeEventsWhenNodeInstanceIdIsNullButEventHasIt() throws Exception {
+    eventHandler.onEvent(
+        successReplicatedEvent("testInstanceId-2", System.currentTimeMillis(), REMOTE));
+
+    RestResponse result = adminRestSession.get(endpoint(project, REMOTE));
+    result.assertOK();
+
+    assertThat(contentWithoutMagicJson(result)).isEqualTo(emptyReplicationStatus(project, REMOTE));
+  }
+
+  @Test
+  public void shouldConsumeEventsWhenBothNodeAndEventHaveNoInstanceId() throws Exception {
+    long eventCreatedOn = System.currentTimeMillis();
+
+    eventHandler.onEvent(successReplicatedEvent(null, eventCreatedOn, REMOTE));
+    RestResponse result = adminRestSession.get(endpoint(project, REMOTE));
+
+    result.assertOK();
+    assertThat(contentWithoutMagicJson(result))
+        .isEqualTo(successReplicationStatus(REMOTE, project, eventCreatedOn));
+  }
+
+  @Test
+  @GerritConfig(name = "gerrit.instanceId", value = "testInstanceId-1")
+  public void shouldShowFailedInPayloadWhenRefCouldntBeReplicated() throws Exception {
+    long eventCreatedOn = System.currentTimeMillis();
+
+    eventHandler.onEvent(failedReplicatedEvent("testInstanceId-1", eventCreatedOn, REMOTE));
+    RestResponse result = adminRestSession.get(endpoint(project, REMOTE));
+
+    result.assertOK();
+    assertThat(contentWithoutMagicJson(result))
+        .isEqualTo(failedReplicationStatus(REMOTE, project, eventCreatedOn));
+  }
+
+  private String contentWithoutMagicJson(RestResponse response) throws IOException {
+    return response.getEntityContent().substring(RestApiServlet.JSON_MAGIC.length);
+  }
+
+  private AccountGroup.UUID createGroup(String name, TestAccount member) throws RestApiException {
+    GroupInput groupInput = new GroupInput();
+    groupInput.name = name(name);
+    groupInput.members = Collections.singletonList(String.valueOf(member.id().get()));
+    return AccountGroup.uuid(gApi.groups().create(groupInput).get().id);
+  }
+
+  private void makeProjectOwner(TestAccount user, Project.NameKey project) throws RestApiException {
+    projectOperations
+        .project(project)
+        .forUpdate()
+        .add(
+            allow(Permission.OWNER)
+                .ref(AccessSection.ALL)
+                .group(createGroup("projectOwners", user)))
+        .update();
+  }
+
+  private RefReplicatedEvent replicatedEvent(
+      @Nullable String instanceId,
+      long when,
+      String ref,
+      String remote,
+      RefPushResult status,
+      RemoteRefUpdate.Status refStatus) {
+    RefReplicatedEvent replicatedEvent =
+        new RefReplicatedEvent(project.get(), ref, remote, status, refStatus);
+    replicatedEvent.instanceId = instanceId;
+    replicatedEvent.eventCreatedOn = when;
+
+    return replicatedEvent;
+  }
+
+  private RefReplicatedEvent successReplicatedEvent(
+      @Nullable String instanceId, long when, String remoteUrl) {
+
+    return replicatedEvent(
+        instanceId,
+        when,
+        REF_MASTER,
+        remoteUrl,
+        RefPushResult.SUCCEEDED,
+        RemoteRefUpdate.Status.OK);
+  }
+
+  private RefReplicatedEvent failedReplicatedEvent(
+      @Nullable String instanceId, long when, String remoteUrl) {
+
+    return replicatedEvent(
+        instanceId,
+        when,
+        REF_MASTER,
+        remoteUrl,
+        RefPushResult.FAILED,
+        RemoteRefUpdate.Status.REJECTED_NONFASTFORWARD);
+  }
+
+  private static String endpoint(Project.NameKey project, String remote) {
+    return String.format(
+        "/projects/%s/remotes/%s/replication-status", project.get(), encode(remote));
+  }
+
+  private String emptyReplicationStatus(Project.NameKey project, String remoteUrl)
+      throws URISyntaxException {
+    return gson.toJson(
+        ProjectReplicationStatus.create(
+            ImmutableMap.of(remoteUrl, RemoteReplicationStatus.create(Collections.emptyMap())),
+            ProjectReplicationStatus.ProjectReplicationStatusResult.OK,
+            project.get()));
+  }
+
+  private String successReplicationStatus(String remote, Project.NameKey project, long when)
+      throws URISyntaxException {
+    return projectReplicationStatus(
+        remote,
+        project,
+        when,
+        ProjectReplicationStatus.ProjectReplicationStatusResult.OK,
+        ReplicationStatus.ReplicationStatusResult.SUCCEEDED);
+  }
+
+  private String failedReplicationStatus(String remote, Project.NameKey project, long when)
+      throws URISyntaxException {
+    return projectReplicationStatus(
+        remote,
+        project,
+        when,
+        ProjectReplicationStatus.ProjectReplicationStatusResult.FAILED,
+        ReplicationStatus.ReplicationStatusResult.FAILED);
+  }
+
+  private String projectReplicationStatus(
+      String remoteUrl,
+      Project.NameKey project,
+      long when,
+      ProjectReplicationStatus.ProjectReplicationStatusResult projectReplicationStatusResult,
+      ReplicationStatus.ReplicationStatusResult replicationStatusResult)
+      throws URISyntaxException {
+    return gson.toJson(
+        ProjectReplicationStatus.create(
+            ImmutableMap.of(
+                remoteUrl,
+                RemoteReplicationStatus.create(
+                    ImmutableMap.of(
+                        REF_MASTER, ReplicationStatus.create(replicationStatusResult, when)))),
+            projectReplicationStatusResult,
+            project.get()));
+  }
+}