Initial revision.

* Document was previously submitted in c/95816:
    src/main/resources/Documentation/*md

* Prolog rules to check "Owner Approval" in:
    src/main/java/find_owners/PRED_*.java
    src/main/prolog/*.pl

* JavaScript to add the "Find Owners" button in:
    src/main/resources/static/find-owners.js

* Java core logic in:
    src/main/java/com/googlesource/gerrit/plugins/findowners/*.java

* Java unit tests, WIP, in:
    src/test/java/com/googlesource/gerrit/plugins/findowners/*.java

Change-Id: Id7721e763dcc7aed344e9ead31715a02893e4b44
diff --git a/BUILD b/BUILD
new file mode 100644
index 0000000..7244080
--- /dev/null
+++ b/BUILD
@@ -0,0 +1,47 @@
+load("//lib/prolog:prolog.bzl", "prolog_cafe_library")
+load("//tools/bzl:junit.bzl", "junit_tests")
+load("//tools/bzl:plugin.bzl", "gerrit_plugin", "PLUGIN_DEPS")
+
+java_library(
+  name = 'find-owners-lib',
+  srcs = glob(['src/main/java/**/*.java']),
+  deps = PLUGIN_DEPS + ['@prolog_runtime//jar'],
+)
+
+prolog_cafe_library(
+  name = 'find-owners-prolog-rules',
+  srcs = glob(['src/main/prolog/*.pl']),
+  deps = [
+    ':find-owners-lib',
+    '//gerrit-server/src/main/prolog:common',
+  ],
+)
+
+gerrit_plugin(
+  name = 'find-owners',
+  srcs = glob(['src/main/java/**/Module.java']),
+  resources = glob(['src/main/resources/**/*']),
+  manifest_entries = [
+    'Gerrit-PluginName: find-owners',
+    'Gerrit-ReloadMode: restart',
+    'Gerrit-HttpModule: com.googlesource.gerrit.plugins.findowners.Servlet',
+    'Gerrit-Module: com.googlesource.gerrit.plugins.findowners.Module',
+    'Implementation-Title: Find-Owners plugin',
+    'Implementation-URL: https://gerrit.googlesource.com/plugins/find-owners',
+  ],
+  deps = [
+    ':find-owners-lib',
+    ':find-owners-prolog-rules',
+  ],
+)
+
+junit_tests(
+  name = 'findowners_tests',
+  srcs = glob(['src/test/java/**/*.java']),
+  # resources = glob(['src/test/resources/**/*']),
+  tags = ['findowners'],
+  deps = PLUGIN_DEPS + [
+    ':find-owners-lib',
+    '//gerrit-acceptance-framework:lib',
+  ],
+)
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..11069ed
--- /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/OWNERS b/OWNERS
new file mode 100644
index 0000000..dbeaad9
--- /dev/null
+++ b/OWNERS
@@ -0,0 +1,2 @@
+chh@google.com
+srhines@google.com
diff --git a/src/main/java/com/googlesource/gerrit/plugins/findowners/Action.java b/src/main/java/com/googlesource/gerrit/plugins/findowners/Action.java
new file mode 100644
index 0000000..48fd7ca
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/findowners/Action.java
@@ -0,0 +1,174 @@
+// Copyright (C) 2017 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.findowners;
+
+import com.google.gerrit.extensions.annotations.PluginCanonicalWebUrl;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.reviewdb.client.Change.Status;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.googlesource.gerrit.plugins.findowners.Util.Owner2Weights;
+import com.googlesource.gerrit.plugins.findowners.Util.String2String;
+import com.googlesource.gerrit.plugins.findowners.Util.String2StringSet;
+import com.googlesource.gerrit.plugins.findowners.Util.StringSet;
+import java.util.Collection;
+import java.util.Map;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Create and return OWNERS info when "Find Owners" button is clicked. */
+class Action implements UiAction<RevisionResource>,
+    RestModifyView<RevisionResource, Action.Input> {
+
+  private static final Logger log = LoggerFactory.getLogger(Action.class);
+
+  private Provider<CurrentUser> user;  // is null if from HTTP request
+  private String url; // from REST request or client action call
+  private Server server;
+
+  static class Input {
+    // Only the change number is required.
+    int    change;   // Revision change number
+    String debug;    // REST API parameter, 1, true/false, yes
+    String patchset; // REST API parameter, patchset number
+
+    Input(int change, String2String params) {
+      this.change = change;
+      debug = params.get("debug");
+      patchset = params.get("patchset");
+      // other keys in params are ignored
+    }
+  }
+
+  @Inject
+  Action(@PluginCanonicalWebUrl String url, Provider<CurrentUser> user) {
+    this.url = Util.normalizeURL(url); // replace "http:///" with "http://"
+    int n = this.url.indexOf("/plugins/");
+    if (n > 0) { // remove suffix "plugins/find-owners/...."
+      this.url = this.url.substring(0, n + 1);
+    }
+    this.user = user;
+    server = new Server();
+  }
+
+  /** Used by unit tests to set up mocked Server. */
+  void setServer(Server s) {
+    server = s;
+  }
+
+  private String getUserName() {
+    return (null != user) ? user.get().getUserName() : "?";
+  }
+
+  private JsonArray getOwners(OwnersDb db, Collection<String> files) {
+    Owner2Weights weights = new Owner2Weights();
+    String2StringSet file2Owners = db.findOwners(files, weights);
+    JsonArray result = new JsonArray();
+    StringSet emails = new StringSet();
+    for (String key : OwnerWeights.sortKeys(weights)) {
+      if (!emails.contains(key)) {
+        result.add(key + " " + weights.get(key).encodeLevelCounts());
+        emails.add(key);
+      }
+    }
+    return result;
+  }
+
+  private void addNamedMap(JsonObject obj, String name,
+                           Map<String, StringSet> map) {
+    JsonObject jsonMap = new JsonObject();
+    for (String key : Util.sort(map.keySet())) {
+      jsonMap.addProperty(key, String.join(" ", Util.sort(map.get(key))));
+    }
+    obj.add(name, jsonMap);
+  }
+
+  /** REST API to return owners info of a change. */
+  public JsonObject getChangeData(int change, String2String params) {
+    return apply(null, new Input(change, params));
+  }
+
+  /** Called by the client "Find Owners" button. */
+  @Override
+  public JsonObject apply(RevisionResource rev, Input input) {
+    server.setChangeId(url, input.change);
+    String error = (null != server.error)
+        ? server.error : server.setPatchId(input.patchset);
+    if (null != error) {
+      JsonObject obj = new JsonObject();
+      obj.addProperty("error", error);
+      return obj;
+    }
+    OwnersDb db = server.getCachedOwnersDb();
+    Collection<String> changedFiles = server.getChangedFiles();
+    String2StringSet file2Owners = db.findOwners(changedFiles);
+
+    JsonObject obj = new JsonObject();
+    obj.addProperty(Config.MIN_OWNER_VOTE_LEVEL, server.getMinOwnerVoteLevel());
+    boolean addDebugMsg = (null != input.debug)
+        ? Util.parseBoolean(input.debug) : server.getAddDebugMsg();
+    obj.addProperty(Config.ADD_DEBUG_MSG, addDebugMsg);
+    obj.addProperty("change", input.change);
+    obj.addProperty("patchset", server.patchset);
+    obj.addProperty("owner_revision", db.revision);
+
+    if (addDebugMsg) {
+      JsonObject dbgMsgObj = new JsonObject();
+      dbgMsgObj.addProperty("user", getUserName());
+      dbgMsgObj.addProperty("project", server.project);
+      dbgMsgObj.addProperty("branch", server.branch);
+      dbgMsgObj.addProperty("server", url);
+      obj.add("dbgmsgs", dbgMsgObj);
+      addNamedMap(obj, "path2owners", db.path2Owners);
+      addNamedMap(obj, "owner2paths", db.owner2Paths);
+    }
+
+    addNamedMap(obj, "file2owners", file2Owners);
+    obj.add("reviewers", server.getReviewers());
+    obj.add("owners", getOwners(db, changedFiles));
+    obj.add("files", Util.newJsonArrayFromStrings(changedFiles));
+    return obj;
+  }
+
+  @Override
+  public Description getDescription(RevisionResource resource) {
+    int change = resource.getChange().getId().get();
+    server.setChangeId(url, change);
+    if (null == server.branch) {
+      log.error("Cannot get branch of change: " + change);
+      return null; // no "Find Owners" button
+    }
+    OwnersDb db = server.getCachedOwnersDb();
+    if (server.traceServerMsg()) {
+      log.info(server.genDebugMsg(db));
+    }
+    Status status = server.getStatus(resource);
+    // Commit message is not used to enable/disable "Find Owners".
+    boolean needFindOwners =
+        (null != user && user.get() instanceof IdentifiedUser)
+        && (db.getNumOwners() > 0)
+        && (status != Status.ABANDONED && status != Status.MERGED);
+    return new Description()
+        .setLabel("Find Owners")
+        .setTitle("Find owners to add to Reviewers list")
+        .setVisible(needFindOwners);
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/findowners/Cache.java b/src/main/java/com/googlesource/gerrit/plugins/findowners/Cache.java
new file mode 100644
index 0000000..b92c790
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/findowners/Cache.java
@@ -0,0 +1,126 @@
+// Copyright (C) 2017 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.findowners;
+
+import java.util.Collection;
+import java.util.Date;
+import java.util.Deque;
+import java.util.HashMap;
+import java.util.LinkedList;
+import org.eclipse.jgit.lib.Repository;
+
+/** Save OwnersDb in a cache for multiple calls to submit_filter. */
+class Cache {
+  // The OwnersDb is created from OWNERS files in directories that
+  // contain changed files of a patch set, which belongs to a project
+  // and branch. OwnersDb can be cached if the head of a project branch
+  // and the patch set are not changed.
+
+  // Although the head of a project branch could be changed by other users,
+  // it is better to assume the same for a patch set during a short period
+  // of time. So multiple checks would have the same result. For example,
+  // one client UI action can trigger multiple HTTP requests.
+  // Each HTTP request has one StoredValues,
+  // and can trigger multiple Prolog submit_filter.
+  // Each submit_filter has one Prolog engine.
+  // It would not be enough to keep the cache in a Prolog engine environment
+  // or a StoredValues.
+  // We keep the cache in a Java class static object for all HTTP requests.
+
+  // OwnersDb is cached for up to 10 seconds.
+  private static final int CACHE_LIFE_MSEC = 10000;
+
+  // When branch is "refs/heads/xyz" use only "xyz",
+  // to share cached OwnersDb between these two branch names.
+  private static final String REFS_HEADS = "refs/heads/";
+
+  static class CachedObj {
+    long time;   // system time in milliseconds, when db is created
+    String key;  // (changeId, patchSetId, branchName)
+    OwnersDb db;
+    CachedObj(String k, OwnersDb x) {
+      time = new Date().getTime();
+      key = k;
+      db = x;
+    }
+  }
+
+  // Before a new CachedObj is added to the tail of dbQueue,
+  // old and obsolete CachedObj are removed from the head.
+  private static final Deque<CachedObj> dbQueue = new LinkedList<CachedObj>();
+
+  // A HashMap provides quick lookup with a key.
+  private static final HashMap<String, CachedObj> dbCache =
+      new HashMap<String, CachedObj>();
+
+  private static long minCachedObjectTime() {
+    // Cached objects must be used within CACHE_LIFE_MSEC.
+    return new Date().getTime() - CACHE_LIFE_MSEC;
+  }
+
+  static String makeKey(int change, int patch, String branch) {
+    if (branch.indexOf(REFS_HEADS) == 0) {
+      branch = branch.substring(REFS_HEADS.length());
+    }
+    return change + ":" + patch + ":" + branch;
+  }
+
+  private static void saveCachedDb(String key, OwnersDb db) {
+    CachedObj obj = new CachedObj(key, db);
+    long minTime = minCachedObjectTime();
+    synchronized (dbCache) {
+      // Remove cached objects older than minTime.
+      while (dbQueue.size() > 0 && dbQueue.peek().time < minTime) {
+        dbCache.remove(dbQueue.peek().key);
+        dbQueue.removeFirst();
+      }
+      // Add the new one to the tail.
+      dbCache.put(key, obj);
+      dbQueue.addLast(obj);
+    }
+  }
+
+  static OwnersDb get(Server server, String key, String url, String project,
+                      String branch, Collection<String> files) {
+    return get(server, key, null, url, project, branch, files);
+  }
+
+  static OwnersDb get(Server server, String key, Repository repository,
+                      String branch, Collection<String> files) {
+    return get(server, key, repository, null, null, branch, files);
+  }
+
+  private static OwnersDb get(
+      Server server, String key, Repository repository, String url,
+      String project, String branch, Collection<String> files) {
+    OwnersDb db = null;
+    long minTime = minCachedObjectTime();
+    synchronized (dbCache) {
+      if (dbCache.containsKey(key)) {
+        CachedObj obj = dbCache.get(key);
+        if (obj.time >= minTime) {
+          db = obj.db;
+        }
+      }
+    }
+    if (null == db) {
+      db = (null != repository)
+          ? new OwnersDb(server, key, repository, branch, files)
+          : new OwnersDb(server, key, url, project, branch, files);
+      saveCachedDb(key, db);
+    }
+    return db;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/findowners/Checker.java b/src/main/java/com/googlesource/gerrit/plugins/findowners/Checker.java
new file mode 100644
index 0000000..4477e17
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/findowners/Checker.java
@@ -0,0 +1,92 @@
+// Copyright (C) 2017 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.findowners;
+
+import com.googlecode.prolog_cafe.lang.Prolog;
+import com.googlesource.gerrit.plugins.findowners.Util.String2Integer;
+import com.googlesource.gerrit.plugins.findowners.Util.String2StringSet;
+import com.googlesource.gerrit.plugins.findowners.Util.StringSet;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Check if a change needs owner approval. */
+public class Checker {
+  private static final Logger log = LoggerFactory.getLogger(Checker.class);
+
+  private Server server;
+  private int minVoteLevel;
+
+  Checker(Server s, int v) {
+    minVoteLevel = v;
+    server = s; // could be a mocked server
+  }
+
+  /** Returns true if some owner in owners is "*" or in votes */
+  boolean findOwnersInVotes(StringSet owners, String2Integer votes) {
+    boolean foundVeto = false;
+    boolean foundApproval = false;
+    for (String owner : owners) {
+      if (votes.containsKey(owner)) {
+        int v = votes.get(owner);
+        // TODO: Maybe add a configurable feature in the next version
+        // to exclude the committer's vote from the "foundApproval".
+        foundApproval |= (v >= minVoteLevel);
+        foundVeto |= (v < 0); // an owner's -1 vote is a veto
+      } else if (owner.equals("*")) {
+        foundApproval = true;  // no specific owner
+      }
+    }
+    return foundApproval && !foundVeto;
+  }
+
+  /** Returns 1 if owner approval is found, -1 if missing, 0 if unneeded. */
+  int findApproval(OwnersDb db) {
+    String2StringSet file2Owners = db.findOwners(server.getChangedFiles());
+    if (file2Owners.size() == 0) {  // do not need owner approval
+      return 0;
+    }
+    String2Integer votes = server.getVotes();
+    for (StringSet owners : file2Owners.values()) {
+      if (!findOwnersInVotes(owners, votes)) {
+        return -1;
+      }
+    }
+    return 1;
+  }
+
+  /** Returns 1 if owner approval is found, -1 if missing, 0 if unneeded. */
+  public static int findApproval(Prolog engine, int minVoteLevel) {
+    return new Checker(new Server(engine), minVoteLevel).findApproval();
+  }
+
+  int findApproval() {
+    if (server.isExemptFromOwnerApproval()) {
+      return 0;
+    }
+    // One update to a Gerrit change can call submit_rule or submit_filter
+    // many times. So this function should use cached values.
+    OwnersDb db = server.getCachedOwnersDb();
+    if (db.getNumOwners() <= 0) {
+      return 0;
+    }
+    if (minVoteLevel <= 0) {
+      minVoteLevel = server.getMinOwnerVoteLevel();
+    }
+    if (server.traceServerMsg()) {
+      log.info(server.genDebugMsg(db));
+    }
+    return findApproval(db);
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/findowners/Config.java b/src/main/java/com/googlesource/gerrit/plugins/findowners/Config.java
new file mode 100644
index 0000000..f6bb5bf
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/findowners/Config.java
@@ -0,0 +1,80 @@
+// Copyright (C) 2017 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.findowners;
+
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.config.PluginConfigFactory;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** find-owners configuration parameters */
+class Config {
+  // Name of config parameters and plugin.
+  static final String ADD_DEBUG_MSG = "addDebugMsg";
+  static final String MIN_OWNER_VOTE_LEVEL = "minOwnerVoteLevel";
+  static final String REPORT_SYNTAX_ERROR = "reportSyntaxError";
+  static final String PLUGIN_NAME = "find-owners";
+  static final String PROLOG_NAMESPACE = "find_owners";
+
+  // Enable TRACE_SERVER_MSG only for dev/test builds.
+  private static final boolean TRACE_SERVER_MSG = false;
+
+  // Global/plugin config parameters.
+  private static PluginConfigFactory config = null;
+  private static boolean addDebugMsg = false;
+  private static int minOwnerVoteLevel = 1;
+  private static boolean reportSyntaxError = false;
+
+  private static final Logger log = LoggerFactory.getLogger(Config.class);
+
+  static void setVariables(PluginConfigFactory conf,
+      boolean dbgMsg, int voteLevel, boolean reportError) {
+    if (TRACE_SERVER_MSG) {
+      log.info("Set config parameters "
+               + ADD_DEBUG_MSG + "=" + dbgMsg
+               + ", " + MIN_OWNER_VOTE_LEVEL + "=" + voteLevel
+               + ", " + REPORT_SYNTAX_ERROR + "=" + reportError);
+    }
+    config = conf;
+    addDebugMsg = dbgMsg;
+    minOwnerVoteLevel = voteLevel;
+    reportSyntaxError = reportError;
+  }
+
+  static boolean traceServerMsg() {
+    return TRACE_SERVER_MSG && addDebugMsg;
+  }
+
+  static boolean getAddDebugMsg() {
+    return addDebugMsg; // defined globally, not per-project
+  }
+
+  static boolean getReportSyntaxError() {
+    return reportSyntaxError;
+  }
+
+  static int getMinOwnerVoteLevel(Project.NameKey project) {
+    try {
+      return (null == config || null == project) ? minOwnerVoteLevel
+          : config.getFromProjectConfigWithInheritance(
+              project, PLUGIN_NAME).getInt(MIN_OWNER_VOTE_LEVEL,
+                                           minOwnerVoteLevel);
+    } catch (NoSuchProjectException e) {
+      log.error("Cannot find project: " + project);
+      return minOwnerVoteLevel;
+    }
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/findowners/GetChange.java b/src/main/java/com/googlesource/gerrit/plugins/findowners/GetChange.java
new file mode 100644
index 0000000..244fd22
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/findowners/GetChange.java
@@ -0,0 +1,116 @@
+// Copyright (C) 2017 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.findowners;
+
+import com.google.gerrit.extensions.annotations.Export;
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonObject;
+import com.google.inject.Singleton;
+import com.googlesource.gerrit.plugins.findowners.Util.String2String;
+import java.io.IOException;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Serves the HTTP GET /change/id request. */
+@Export("/change/*")
+@Singleton
+class GetChange extends HttpServlet {
+  private static final Logger log = LoggerFactory.getLogger(GetChange.class);
+
+  private static final String MAGIC_PREFIX = ")]}'";  // Gerrit REST specific
+
+  private int getChangeId(String url) {
+    // Expect url=".../plugins/find-owners/change/<digits>"
+    final Pattern patChangeId =
+      Pattern.compile("^.*/plugins/" + Config.PLUGIN_NAME
+                      + "/change/([0-9]+)/?$");
+    Matcher m = patChangeId.matcher(url);
+    return m.find() ? Integer.parseInt(m.group(1)) : 0;
+  }
+
+  private String2String parseParameters(HttpServletRequest req) {
+    Map<String, String[]> map = req.getParameterMap();
+    String2String params = new String2String();
+    for (Map.Entry<String, String[]> entry : map.entrySet()) {
+      String[] value = entry.getValue();
+      // Use only the last definition, if there are multiple.
+      params.put(entry.getKey(), value[value.length - 1]);
+    }
+    return params;
+  }
+
+  protected void doGet(HttpServletRequest req, HttpServletResponse res)
+      throws IOException {
+    String reqURL = req.getRequestURL().toString();
+    // e.g. http://localhost:8082/plugins/find-owners/change/36
+    String localAddress = req.getLocalAddr(); // e.g. 100.108.228.206
+    int localPort = req.getLocalPort(); // e.g. 8080, not client port number
+    String2String params = parseParameters(req);
+    String url = "http://" + localAddress + ":" + localPort + "/";
+    // TODO: recognize pp=0 parameter and Accept HTTP request header
+    // to output compact JSON.
+    int changeId = getChangeId(reqURL);
+    if (Config.traceServerMsg()) {
+      String paramsDump = "";
+      for (Map.Entry<String, String> entry : params.entrySet()) {
+        paramsDump += " " + entry.getKey() + "=" + entry.getValue();
+      }
+      log.info("goGet reqURL=" + reqURL
+               + ", address=" + localAddress + ", port=" + localPort
+               + ", changeId=" + changeId + ", params:" + paramsDump);
+    }
+    createResponse(url, params, changeId, res);
+  }
+
+  private void createErrorResponse(HttpServletResponse res,
+                                   String msg) throws IOException {
+    res.setContentType("text/plain");
+    res.getWriter().println("Error: " + msg);
+  }
+
+  private void createResponse(
+      String url, String2String params, int changeId,
+      HttpServletResponse res) throws IOException {
+    res.setCharacterEncoding("UTF-8");
+    if (changeId > 0) {
+      Action finder = new Action(url, null);
+      JsonObject obj = finder.getChangeData(changeId, params);
+      if (null != obj.get("error")) {
+        createErrorResponse(res, obj.get("error").getAsString());
+      } else {
+        // Current prototype always returns pretty-printed JSON.
+        // TODO: recognize HTTP Accept-Encoding request header "gzip",
+        // to gzip compress the response.
+        Gson gs = new GsonBuilder()
+            .setPrettyPrinting().disableHtmlEscaping().create();
+        res.setContentType("application/json");
+        res.getWriter().println(MAGIC_PREFIX);
+        res.getWriter().println(gs.toJson(obj));
+      }
+    } else {
+      createErrorResponse(res,
+        "Missing change number.\n"
+        + "Usage: <baseURL>/plugins/"
+        + Config.PLUGIN_NAME + "/change/<changeId>");
+    }
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/findowners/Module.java b/src/main/java/com/googlesource/gerrit/plugins/findowners/Module.java
new file mode 100644
index 0000000..151ccba
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/findowners/Module.java
@@ -0,0 +1,67 @@
+// Copyright (C) 2017 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.findowners;
+
+import static com.google.gerrit.server.change.RevisionResource.REVISION_KIND;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.extensions.annotations.Listen;
+import com.google.gerrit.extensions.annotations.PluginName;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.restapi.RestApiModule;
+import com.google.gerrit.extensions.webui.JavaScriptPlugin;
+import com.google.gerrit.extensions.webui.WebUiPlugin;
+import com.google.gerrit.rules.PredicateProvider;
+import com.google.gerrit.server.config.PluginConfig;
+import com.google.gerrit.server.config.PluginConfigFactory;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+
+/** find-owners plugin module */
+public class Module extends AbstractModule {
+  /** Prolog Predicate Provider.  */
+  @Listen
+  static class FindOwnersProvider implements PredicateProvider {
+    @Override
+    public ImmutableSet<String> getPackages() {
+      return ImmutableSet.of(Config.PROLOG_NAMESPACE);
+    }
+  }
+
+  @Inject
+  public Module(@PluginName String pluginName, PluginConfigFactory config) {
+    // The 'true' parameter does not seem to reread gerrit.config on restart.
+    PluginConfig c = config.getFromGerritConfig(pluginName, true);
+    // TODO: get config parameters from plugin config file.
+    Config.setVariables(config,
+                        c.getBoolean(Config.ADD_DEBUG_MSG, false),
+                        c.getInt(Config.MIN_OWNER_VOTE_LEVEL, 1),
+                        c.getBoolean(Config.REPORT_SYNTAX_ERROR, false));
+  }
+
+  @Override
+  protected void configure() {
+    install(new RestApiModule() {
+      @Override
+      protected void configure() {
+        post(REVISION_KIND, Config.PLUGIN_NAME).to(Action.class);
+      }
+    });
+    DynamicSet.bind(binder(), WebUiPlugin.class)
+        .toInstance(new JavaScriptPlugin(Config.PLUGIN_NAME + ".js"));
+    DynamicSet.bind(binder(), PredicateProvider.class)
+        .to(FindOwnersProvider.class);
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/findowners/OwnerWeights.java b/src/main/java/com/googlesource/gerrit/plugins/findowners/OwnerWeights.java
new file mode 100644
index 0000000..5664349
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/findowners/OwnerWeights.java
@@ -0,0 +1,102 @@
+// Copyright (C) 2017 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.findowners;
+
+import com.googlesource.gerrit.plugins.findowners.Util.Owner2Weights;
+import com.googlesource.gerrit.plugins.findowners.Util.StringSet;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+
+/**
+ * Keep owned files and count number of files at control level 1, 2, 3, etc.
+ *
+ * <p>A source file can be owned by multiple OWNERS file in its directory or
+ *    parent directories. The owners listed in the lowest OWNERS file has
+ *    level 1 control of that source file. The 2nd lowest OWNERS file has
+ *    level 2 control, etc.
+ * <p>An owner can own multiple source files at different control level.
+ * <p>Each owner has an OwnerWeights object to keep
+ *    (0) the set of owned files,
+ *    (1) number of owned files with level 1 control,
+ *    (2) number of owned files with level 2 control,
+ *    (3) number of owned files with level 3 or higher control,
+ */
+class OwnerWeights {
+  static class WeightComparator implements Comparator<String> {
+    private Owner2Weights map;
+    WeightComparator(Owner2Weights weights) {
+      map = weights;
+    }
+    @Override
+    public int compare(String k1, String k2) {
+      OwnerWeights w1 = map.get(k1);
+      OwnerWeights w2 = map.get(k2);
+      int n1 = w2.countL1 - w1.countL1;
+      int n2 = w2.countL2 - w1.countL2;
+      int n3 = w2.countL3 - w1.countL3;
+      return n1 != 0 ? n1 : (n2 != 0 ? n2 : (n3 != 0 ? n3 : k1.compareTo(k2)));
+    }
+  }
+
+  StringSet files;  /** paths of owned files */
+  int countL1;  /** number of files with control level 1 */
+  int countL2;  /** number of files with control level 2 */
+  int countL3;  /** number of files with control level 3 or more */
+
+  /** Return file counters as a compact string. */
+  String encodeLevelCounts() {
+    return "[" + countL1 + "+" + countL2 + "+" + countL3 + "]";
+  }
+
+  private void init() {
+    files = new StringSet();
+    countL1 = 0;
+    countL2 = 0;
+    countL3 = 0;
+  }
+
+  OwnerWeights(String file, int level) {
+    init();
+    addFile(file, level);
+  }
+
+  OwnerWeights() {
+    init();
+  }
+
+  void addFile(String path, int level) {
+    // If a file is added multiple times,
+    // it should be added with lowest level first.
+    if (!files.contains(path)) {
+      files.add(path);
+      if (level <= 1) {
+        countL1++;
+      } else if (level <= 2) {
+        countL2++;
+      } else {
+        countL3++;
+      }
+    }
+  }
+
+  /** Sort keys in weights map by control levels, and return keys. */
+  static List<String> sortKeys(Owner2Weights weights) {
+    ArrayList<String> keys = new ArrayList(weights.keySet());
+    Collections.sort(keys, new WeightComparator(weights));
+    return keys;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/findowners/OwnersDb.java b/src/main/java/com/googlesource/gerrit/plugins/findowners/OwnersDb.java
new file mode 100644
index 0000000..503da41
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/findowners/OwnersDb.java
@@ -0,0 +1,245 @@
+// Copyright (C) 2017 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.findowners;
+
+import com.googlesource.gerrit.plugins.findowners.Util.Owner2Weights;
+import com.googlesource.gerrit.plugins.findowners.Util.String2StringSet;
+import com.googlesource.gerrit.plugins.findowners.Util.StringSet;
+import java.nio.file.FileSystem;
+import java.nio.file.FileSystems;
+import java.nio.file.PathMatcher;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Collection;
+import org.eclipse.jgit.lib.Repository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Keep all information about owners and owned files. */
+class OwnersDb {
+  private static final Logger log = LoggerFactory.getLogger(OwnersDb.class);
+
+  private Server server; // could be set to a mocked server in unit tests
+
+  private int numOwners; // # of owners of all given files.
+
+  String revision; // tip of branch revision, where OWENRS were found.
+  String2StringSet dir2Globs; // (directory) => file globs in the directory
+  String2StringSet owner2Paths; // (owner email) => owned dirs or file globs
+  String2StringSet path2Owners; // (directory or file glob) => owner emails
+  StringSet readDirs; // directories in which we have checked OWNERS
+  StringSet stopLooking; // directories where OWNERS has "set noparent"
+
+  private void init(Server s) {
+    numOwners = -1;
+    revision = "";
+    dir2Globs = new String2StringSet();
+    owner2Paths = new String2StringSet();
+    path2Owners = new String2StringSet();
+    readDirs = new StringSet();
+    stopLooking = new StringSet();
+    server = (null != s) ? s : new Server();
+  }
+
+  OwnersDb(Server s) {
+    init(s);
+  }
+
+  OwnersDb(Server s, String key, Repository repository,
+           String branch, Collection<String> files) {
+    init(s, key, repository, null, null, branch, files);
+  }
+
+  OwnersDb(Server s, String key, String url, String project,
+           String branch, Collection<String> files) {
+    init(s, key, null, url, project, branch, files);
+  }
+
+  int getNumOwners() {
+    return (numOwners >= 0) ? numOwners : owner2Paths.keySet().size();
+  }
+
+  private void countNumOwners(Collection<String> files) {
+    String2StringSet file2Owners = findOwners(files, null);
+    if (null != file2Owners) {
+      StringSet emails = new StringSet();
+      for (String key : file2Owners.keySet()) {
+        for (String owner : file2Owners.get(key)) {
+          emails.add(owner);
+        }
+      }
+      numOwners = emails.size();
+    } else {
+      numOwners = owner2Paths.keySet().size();
+    }
+  }
+
+  private static void addToMap(String2StringSet map,
+                               String key, String value) {
+    if (null == map.get(key)) {
+      map.put(key, new StringSet());
+    }
+    map.get(key).add(value);
+  }
+
+  void addOwnerPathPair(String owner, String path) {
+    addToMap(owner2Paths, owner, path);
+    addToMap(path2Owners, path, owner);
+    if (path.length() > 0 && path.charAt(path.length() - 1) != '/') {
+      addToMap(dir2Globs, Util.getDirName(path) + "/", path); // A file glob.
+    }
+  }
+
+  void addFile(String path, String file, String[] lines) {
+    int n = 0;
+    for (String line : lines) {
+      String error = Parser.parseLine(this, path, file, line, ++n);
+      if (null != error && server.getReportSyntaxError()) {
+        log.warn(error);
+      }
+    }
+  }
+
+  private void addOwnerWeights(
+      ArrayList<String> paths, ArrayList<Integer> distances,
+      String file, String2StringSet file2Owners, Owner2Weights map) {
+    for (int i = 0; i < paths.size(); i++) {
+      StringSet owners = path2Owners.get(paths.get(i));
+      if (null == owners) {
+        continue;
+      }
+      for (String name : owners) {
+        addToMap(file2Owners, file, name);
+        if (null == map) {
+          continue;
+        }
+        if (map.containsKey(name)) {
+          map.get(name).addFile(file, distances.get(i));
+        } else {
+          map.put(name, new OwnerWeights(file, distances.get(i)));
+        }
+      }
+    }
+  }
+
+  /** Quick method to find owner emails of every file. */
+  String2StringSet findOwners(Collection<String> files) {
+    return findOwners(files, null);
+  }
+
+  /** Returns owner emails of every file and set up ownerWeights. */
+  String2StringSet findOwners(Collection<String> files,
+                              Owner2Weights ownerWeights) {
+    return findOwners(files.toArray(new String[0]), ownerWeights);
+  }
+
+  /** Returns true if path has '*' owner. */
+  private boolean findStarOwner(String path, int distance,
+                                ArrayList<String> paths,
+                                ArrayList<Integer> distances) {
+    StringSet owners = path2Owners.get(path);
+    if (null != owners) {
+      paths.add(path);
+      distances.add(new Integer(distance));
+      if (owners.contains("*")) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  /** Returns owner emails of every file and set up ownerWeights. */
+  String2StringSet findOwners(String[] files, Owner2Weights ownerWeights) {
+    // Returns a map of file to set of owner emails.
+    // If ownerWeights is not null, add to it owner to distance-from-dir;
+    // a distance of 1 is the lowest/closest possible distance
+    // (which makes the subsequent math easier).
+    String2StringSet file2Owners = new String2StringSet();
+    for (String fileName : files) {
+      fileName = Util.normalizedFilePath(fileName);
+      String dirPath = Util.normalizedDirPath(fileName);
+      String baseName = fileName.substring(dirPath.length() + 1);
+      int distance = 1;
+      FileSystem fileSystem = FileSystems.getDefault();
+      // Collect all matched (path, distance) in all OWNERS files for
+      // fileName. Add them only if there is no special "*" owner.
+      ArrayList<String> paths = new ArrayList<String>();
+      ArrayList<Integer> distances = new ArrayList<Integer>();
+      boolean foundStar = false;
+      while (true) {
+        int savedSizeOfPaths = paths.size();
+        if (dir2Globs.containsKey(dirPath + "/")) {
+          StringSet patterns = dir2Globs.get(dirPath + "/");
+          for (String pat : patterns) {
+            PathMatcher matcher = fileSystem.getPathMatcher("glob:" + pat);
+            if (matcher.matches(Paths.get(dirPath + "/" + baseName))) {
+              foundStar |= findStarOwner(pat, distance, paths, distances);
+              // Do not break here, a file could match multiple globs
+              // with different owners.
+              // OwnerWeights.add won't add duplicated files.
+            }
+          }
+          // NOTE: A per-file directive can only specify owner emails,
+          // not "set noparent".
+        }
+        // If baseName does not match per-file glob, paths is not changed.
+        // Then we should check the general non-per-file owners.
+        if (paths.size() == savedSizeOfPaths) {
+          foundStar |= findStarOwner(dirPath + "/", distance, paths, distances);
+        }
+        if (foundStar // This file can be approved by anyone, no owner.
+            || stopLooking.contains(dirPath + "/") // stop looking parent
+            || !dirPath.contains("/") /* root */ ) {
+          break;
+        }
+        if (paths.size() != savedSizeOfPaths) {
+          distance++;  // increase distance for each found OWNERS
+        }
+        dirPath = Util.getDirName(dirPath); // go up one level
+      }
+      if (!foundStar) {
+        addOwnerWeights(paths, distances, fileName,
+                        file2Owners, ownerWeights);
+      }
+    }
+    return file2Owners;
+  }
+
+  private void init(
+      Server s, String key, Repository repository, String url,
+      String project, String branch, Collection<String> files) {
+    init(s);
+    for (String fileName : files) {
+      // Find OWNERS in fileName's directory and parent directories.
+      // Stop looking for a parent directory if OWNERS has "set noparent".
+      fileName = Util.normalizedFilePath(fileName);
+      String dir = Util.normalizedDirPath(fileName);
+      while (!readDirs.contains(dir)) {
+        readDirs.add(dir);
+        String content = server.getOWNERS(dir, repository, url,
+                                          project, branch);
+        if (null != content && !content.equals("")) {
+          addFile(dir + "/", dir + "/OWNERS", content.split("\\R+"));
+        }
+        if (stopLooking.contains(dir + "/") || !dir.contains("/")) {
+          break; // stop looking through parent directory
+        }
+        dir = Util.getDirName(dir); // go up one level
+      }
+    }
+    countNumOwners(files);
+    revision = server.getBranchRevision(repository, url, project, branch);
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/findowners/Parser.java b/src/main/java/com/googlesource/gerrit/plugins/findowners/Parser.java
new file mode 100644
index 0000000..e1dfa8a
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/findowners/Parser.java
@@ -0,0 +1,108 @@
+// Copyright (C) 2017 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.findowners;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Parse lines in an OWNERS file and put them into an OwnersDb.
+ *
+ * <p>OWNERS file syntax:<pre>
+ * lines     := (\s* line? \s* "\n")*
+ * line      := "set noparent"
+ *           | "per-file" \s+ glob \s* "=" \s* directive
+ *           | "file:" glob
+ *           | comment
+ *           | directive
+ * directive := email_address
+ *           |  "*"
+ * glob      := [a-zA-Z0-9_-*?]+
+ * comment   := "#" [^"\n"]*
+ * </pre>
+ * <p> The "file:" directive is not implemented yet.
+ * <p> "per-file glob = directive" applies directive only to files
+ *     matching glob. glob does not contain directory path.
+ */
+class Parser {
+  private static final Logger log = LoggerFactory.getLogger(Parser.class);
+
+  static final Pattern PatComment = Pattern.compile("^ *(#.*)?$");
+  // TODO: have a more precise email address pattern.
+  static final Pattern PatEmail =  // email address or a "*"
+      Pattern.compile("^ *([^ <>@]+@[^ <>@#]+|\\*) *(#.*)?$");
+  static final Pattern PatFile = Pattern.compile("^ *file:.*$");
+  static final Pattern PatNoParent =
+      Pattern.compile("^ *set +noparent(( |#).*)?$");
+  static final Pattern PatPerFile =
+      Pattern.compile("^ *per-file +([^= ]+) *= *([^ #]+).*$");
+
+  /**
+   *  Parse a line in OWNERS file and add info to OwnersDb.
+   *
+   *  @param db   an OwnersDb to keep parsed info.
+   *  @param path the path of OWNERS file.
+   *  @param file the OWNERS file path.
+   *  @param line the source line.
+   *  @param num  the line number.
+   *  @return error message string or null.
+   */
+  static String parseLine(OwnersDb db, String path,
+                          String file, String line, int num) {
+    // comment and file: directive are parsed but ignored.
+    if (PatNoParent.matcher(line).find()) {
+      db.stopLooking.add(path);
+      return null;
+    }
+    if (PatPerFile.matcher(line).find()) {
+      Matcher m = PatPerFile.matcher(line);
+      m.find();
+      return parseDirective(db, path + m.group(1), file, m.group(2), num);
+    }
+    if (PatFile.matcher(line).find()) {
+      return warningMsg(file, num, "ignored", line);
+    }
+    // ignore comment and empty lines.
+    return (PatComment.matcher(line).find())
+        ? null : parseDirective(db, path, file, line, num);
+  }
+
+  private static String parseDirective(OwnersDb db, String pathGlob,
+                                       String file, String line, int num) {
+    // A directive is an email address or "*".
+    if (PatEmail.matcher(line).find()) {
+      Matcher m = PatEmail.matcher(line);
+      m.find();
+      db.addOwnerPathPair(m.group(1), pathGlob);
+      return null;
+    }
+    return errorMsg(file, num, "ignored unknown line", line);
+  }
+
+  private static String createMsgLine(
+      String prefix, String file, int n, String msg, String line) {
+    return prefix + file + ":" + n + ": " + msg + ": [" + line + "]";
+  }
+
+  static String errorMsg(String file, int n, String msg, String line) {
+    return createMsgLine("Error: ", file, n, msg, line);
+  }
+
+  static String warningMsg(String file, int n, String msg, String line) {
+    return createMsgLine("Warning: ", file, n, msg, line);
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/findowners/Server.java b/src/main/java/com/googlesource/gerrit/plugins/findowners/Server.java
new file mode 100644
index 0000000..98a75aa
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/findowners/Server.java
@@ -0,0 +1,304 @@
+// Copyright (C) 2017 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.findowners;
+
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Change.Status;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.AccountAccess;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.rules.StoredValues;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gwtorm.server.OrmException;
+import com.googlecode.prolog_cafe.lang.Prolog;
+import com.googlesource.gerrit.plugins.findowners.Util.String2Integer;
+import com.googlesource.gerrit.plugins.findowners.Util.StringSet;
+import java.io.IOException;
+import java.net.URLEncoder;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Map;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevTree;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
+import org.eclipse.jgit.treewalk.filter.PathFilter;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Wrapper of Gerrit server functions. */
+class Server {
+  // Usage: construct one instance and init for each change id.
+  private static final Logger log = LoggerFactory.getLogger(Server.class);
+
+  // Accept both "Exempt-" and "Exempted-".
+  private static final String EXEMPT_MESSAGE1 = "Exempt-From-Owner-Approval:";
+  private static final String EXEMPT_MESSAGE2 = "Exempted-From-Owner-Approval:";
+
+  private Prolog engine; // Gerrit Prolog engine
+  String url; // Gerrit server URL, could be changed in mocked server
+
+  int change;
+  int patchset; // patchset number
+  String project;
+  String branch;
+  String error; // default init to null
+  Collection<PatchSetApproval> approvals; // only used with Prolog engine
+
+  Server() {}
+
+  Server(Prolog p) {
+    engine = p;
+    ChangeData data = StoredValues.CHANGE_DATA.get(engine);
+    change = data.getId().get();
+    try {
+      patchset = data.currentPatchSet().getId().get();
+    } catch (OrmException e) {
+      log.error("Cannot get patchset: " + e);
+      patchset = 1;
+    }
+    Change c = StoredValues.getChange(engine);
+    project = c.getProject().get();
+    // NOTE: repository.getBranch() returns incorrect "master"
+    branch = c.getDest().get(); // e.g. "refs/heads/BetaBranch"
+    try {
+      approvals = data.currentApprovals();
+    } catch (OrmException e) {
+      log.error("Cannot get approvals: " + e);
+      approvals = new ArrayList<PatchSetApproval>();
+    }
+  }
+
+  int getMinOwnerVoteLevel() {
+    return Config.getMinOwnerVoteLevel(new Project.NameKey(project));
+  }
+
+  boolean getAddDebugMsg() {
+    return Config.getAddDebugMsg();
+  }
+
+  boolean traceServerMsg() {
+    return Config.traceServerMsg();
+  }
+
+  boolean getReportSyntaxError() {
+    return Config.getReportSyntaxError();
+  }
+
+  /** Returns a revision's change review status. */
+  Status getStatus(RevisionResource resource) {
+    return resource.getChange().getStatus();
+  }
+
+  /** Sets change number; retrieves other parameters from REST API. */
+  void setChangeId(String url, int change) {
+    this.url = url;
+    this.change = change;
+    String request = url + "changes/?q=" + change + "&o=CURRENT_REVISION";
+    JsonArray arr = Util.getHTTPJsonArray(request);
+    if (null == arr || arr.size() != 1) {
+      error = "Failed request: " + request;
+      return;
+    }
+    JsonObject obj = arr.get(0).getAsJsonObject();
+    project = obj.get("project").getAsString();
+    branch = obj.get("branch").getAsString();
+    String revisionString = obj.get("current_revision").getAsString();
+    JsonObject revisions = obj.get("revisions").getAsJsonObject();
+    JsonObject revInfo = revisions.get(revisionString).getAsJsonObject();
+    patchset = revInfo.get("_number").getAsInt();
+  }
+
+  /** Returns error message if patchsetNum has invalid value. */
+  String setPatchId(String patchsetNum) {
+    if (null != patchsetNum) {
+      int n = Integer.parseInt(patchsetNum);
+      if (n < 1 || n > patchset) {
+        return "Invalid patchset parameter: " + patchsetNum + "; must be 1"
+            + ((1 != patchset) ? (" to " + patchset) : "");
+      }
+      patchset = n;
+    }
+    return null;
+  }
+
+  /** Returns a map from reviewer email to vote value; uses Prolog engine. */
+  String2Integer getVotes() {
+    ChangeData data = StoredValues.CHANGE_DATA.get(engine);
+    ReviewDb db = StoredValues.REVIEW_DB.get(engine);
+    String2Integer map = new String2Integer();
+    AccountAccess ac = db.accounts();
+    for (PatchSetApproval p : approvals) {
+      if (p.getValue() != 0) {
+        int id = p.getAccountId().get();
+        try {
+          Account a = ac.get(new Account.Id(id));
+          String email = a.getPreferredEmail();
+          map.put(email, new Integer(p.getValue()));
+        } catch (OrmException e) {
+          log.error("Cannot get email address of account id: " + id + " " + e);
+        }
+      }
+    }
+    return map;
+  }
+
+  /** Returns changed files, uses Prolog engine or url REST API. */
+  Collection<String> getChangedFiles() {
+    if (null != engine) { // Get changed files faster from StoredValues.
+      try {
+        return StoredValues.CHANGE_DATA.get(engine).currentFilePaths();
+      } catch (OrmException e) {
+        log.error("OrmException in getChangedFiles: " + e);
+        return new StringSet();
+      }
+    }
+    String request =
+        url + "changes/" + change + "/revisions/" + patchset + "/files";
+    JsonObject map = Util.getHTTPJsonObject(request, false);
+    StringSet result = new StringSet();
+    for (Map.Entry<String, JsonElement> entry : map.entrySet()) {
+      String key = entry.getKey();
+      if (!key.equals("/COMMIT_MSG")) { // ignore commit message
+        result.add(key);
+        // If a file was moved then we need approvals for old directory.
+        JsonObject attr = entry.getValue().getAsJsonObject();
+        if (null != attr && null != attr.get("old_path")) {
+          result.add(attr.get("old_path").getAsString());
+        }
+      }
+    }
+    return result;
+  }
+
+  /** Returns reviewer emails got from url REST API. */
+  JsonArray getReviewers() {
+    String request = url + "changes/" + change + "/reviewers";
+     JsonArray reviewers = Util.getHTTPJsonArray(request);
+     JsonArray result = new JsonArray();
+     int numReviewers = reviewers.size();
+     for (int i = 0; i < numReviewers; i++) {
+       JsonObject map = reviewers.get(i).getAsJsonObject();
+       result.add(map.get("email").getAsString() + " []");
+     }
+     return result;
+  }
+
+  /** Returns file content or empty string; uses Repository. */
+  String getRepositoryFile(Repository repo, String branch, String file) {
+    try (RevWalk revWalk = new RevWalk(repo)) {
+      RevTree tree = revWalk.parseCommit(repo.resolve(branch)).getTree();
+      try (TreeWalk treeWalk = new TreeWalk(repo)) {
+        try (ObjectReader reader = repo.newObjectReader()) {
+          treeWalk.addTree(tree);
+          treeWalk.setRecursive(true);
+          treeWalk.setFilter(PathFilter.create(file));
+          if (treeWalk.next()) {
+            return new String(reader.open(treeWalk.getObjectId(0)).getBytes());
+          }
+        }
+      }
+    } catch (IOException e) {
+      log.error("get file " + file + ": " + e);
+    }
+    return "";
+  }
+
+  /** Returns OWNERS file content; uses Repository or url REST API. */
+  String getOWNERS(String dir, Repository repository, String url,
+                   String project, String branch) {
+    // e.g. dir = ./d1/d2
+    String filePath = (dir + "/OWNERS").substring(2);  // remove "./"
+    if (null != repository) {
+      return getRepositoryFile(repository, branch, filePath);
+    } else {
+      String requestUrl = url + "projects/" + project
+          + "/branches/" + branch + "/files/"
+          + URLEncoder.encode(filePath) + "/content";
+      return Util.getHTTPBase64Content(requestUrl);
+    }
+  }
+
+  /** Returns the revision string of the tip of target branch. */
+  String getBranchRevision(Repository repository, String url,
+                           String project, String branch) {
+    if (null != repository) {
+      try {
+        return repository.getRef(
+            repository.getBranch()).getObjectId().getName();
+      } catch (IOException e) {
+        log.error("Fail to get branch revision: " + e);
+      }
+    } else {
+      JsonObject obj = Util.getHTTPJsonObject(
+          url + "projects/" + project + "/branches/" + branch, true);
+      // cannot get revision of branch "refs/meta/config".
+      if (null != obj && null != obj.get("revision")) {
+        return obj.get("revision").getAsString();
+      }
+    }
+    return "";
+  }
+
+  /** Returns true if exempt from owner approval; uses Prolog engine. */
+  boolean isExemptFromOwnerApproval() {
+    if (null == engine) {
+      return true;
+    }
+    try {
+      ChangeData data = StoredValues.CHANGE_DATA.get(engine);
+      String message = data.commitMessage();
+      if (message.contains(EXEMPT_MESSAGE1)
+          || message.contains(EXEMPT_MESSAGE2)) {
+        return true;
+      }
+    } catch (IOException | OrmException e) {
+      log.error("Cannot get commit message: " + e);
+      return true;  // exempt from owner approval due to lack of data
+    }
+    // Abandoned and merged changes do not need approval again.
+    Status status = StoredValues.getChange(engine).getStatus();
+    return (status == Status.ABANDONED || status == Status.MERGED);
+  }
+
+  /** Returns a cached or new OwnersDb. */
+  OwnersDb getCachedOwnersDb() {
+    if (null != engine) { // Get changed files faster from StoredValues.
+      Repository repository = StoredValues.REPOSITORY.get(engine);
+      String dbKey = Cache.makeKey(change, patchset, branch);
+      return Cache.get(this, dbKey, repository, branch, getChangedFiles());
+    }
+    String key = Cache.makeKey(change, patchset, branch);
+    return Cache.get(this, key, url, project, branch, getChangedFiles());
+  }
+
+  /** Returns a debug message string, for server side logging. */
+  String genDebugMsg(OwnersDb db) {
+    return (null == url ? "" : ("\n## url=" + url))
+           + "\n## change=" + change + ", patchset=" + patchset
+           + ", project=" + project + ", branch=" + branch
+           + "\n## changedFiles=" + getChangedFiles()
+           + "\nnumOwners=" + db.getNumOwners()
+           + ", minVoteLevel=" + getMinOwnerVoteLevel()
+           + ", approvals=" + getVotes();
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/findowners/Servlet.java b/src/main/java/com/googlesource/gerrit/plugins/findowners/Servlet.java
new file mode 100644
index 0000000..590290a
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/findowners/Servlet.java
@@ -0,0 +1,24 @@
+// Copyright (C) 2017 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.findowners;
+
+import com.google.inject.servlet.ServletModule;
+
+/** Plugin HTTP Servlet module */
+public class Servlet extends ServletModule {
+  protected void configureServlets() {
+    serve("/change/*").with(GetChange.class);
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/findowners/Util.java b/src/main/java/com/googlesource/gerrit/plugins/findowners/Util.java
new file mode 100644
index 0000000..e0ca853
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/findowners/Util.java
@@ -0,0 +1,132 @@
+// Copyright (C) 2017 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.findowners;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import com.googlesource.gerrit.plugins.findowners.Util.Owner2Weights;
+import com.googlesource.gerrit.plugins.findowners.Util.String2StringSet;
+import java.io.File;
+import java.io.IOException;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.net.URLConnection;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Scanner;
+import java.util.Set;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Utility classes and functions. */
+class Util {
+  private static final Logger log = LoggerFactory.getLogger(Util.class);
+
+  static class Owner2Weights extends HashMap<String, OwnerWeights> {}
+  static class String2Integer extends HashMap<String, Integer> {}
+  static class String2String extends HashMap<String, String> {}
+  static class String2StringSet extends HashMap<String, StringSet> {}
+  static class StringSet extends HashSet<String> {}
+
+  /** Removes extra "/" in url. */
+  static String normalizeURL(String url) {
+    return url.replace(":///", "://"); // Assuming only one ":///" in url.
+  }
+
+  /** Strips REST magic prefix line. */
+  static String stripMagicPrefix(String data) {
+    final String magic = ")]}'\n";
+    return data.startsWith(magic) ? data.substring(magic.length()) : data;
+  }
+
+  /** Issues Gerrit REST API GET command. */
+  private static String getHTTP(String urlString, boolean ignoreIOException) {
+    urlString = normalizeURL(urlString);
+    try {
+      URLConnection conn = new URL(urlString).openConnection();
+      Scanner scanner = new Scanner(conn.getInputStream());
+      String responseBody = scanner.useDelimiter("\\A").next();
+      return stripMagicPrefix(responseBody);
+    } catch (MalformedURLException e) {
+      log.error("Malformed URL: " + urlString);
+    } catch (IOException e) {
+      // Not an error if looking for an OWNERS file
+      // or revision info in the "refs/meta/config" branch.
+      if (!ignoreIOException) {
+        log.error("IOException URL: " + urlString);
+      }
+    }
+    return null;
+  }
+
+  /** Issues Gerrit REST API GET; converts result to JsonObject. */
+  static JsonObject getHTTPJsonObject(String url, boolean ignoreIOException) {
+    String data = getHTTP(url, ignoreIOException);
+    return (null == data) ? new JsonObject()
+        : new JsonParser().parse(data).getAsJsonObject();
+  }
+
+  /** Issues Gerrit REST API GET; converts result to JsonArray. */
+  static JsonArray getHTTPJsonArray(String url) {
+    String data = getHTTP(url, false);
+    return (null == data) ?  new JsonArray()
+        : new JsonParser().parse(data).getAsJsonArray();
+  }
+
+  /** Issues Gerrit REST API GET; decodes base64 content. */
+  static String getHTTPBase64Content(String url) {
+    String data = getHTTP(url, true);
+    return (null == data) ? "" : new String(Base64.getDecoder().decode(data));
+  }
+
+  static String getDirName(String path) {
+    return new File(path).getParent();
+  }
+
+  static String normalizedFilePath(String path) {
+    return path.startsWith("./") ? path : ("./" + path);
+  }
+
+  static String normalizedDirPath(String path) {
+    return new File(normalizedFilePath(path)).getParent();
+  }
+
+  static boolean parseBoolean(String s) {
+    return (null != s) && (s.equals("1")
+        || s.equalsIgnoreCase("yes") || Boolean.parseBoolean(s));
+  }
+
+  static List<String> sort(Set<String> names) {
+    List<String> list = new ArrayList<String>(names);
+    Collections.sort(list);
+    return list;
+  }
+
+  static JsonArray newJsonArrayFromStrings(Collection<String> names) {
+    JsonArray result = new JsonArray();
+    List<String> list = new ArrayList<String>(names);
+    Collections.sort(list);
+    for (String name : list) {
+      result.add(name);
+    }
+    return result;
+  }
+}
diff --git a/src/main/java/find_owners/PRED_check_owner_approval_2.java b/src/main/java/find_owners/PRED_check_owner_approval_2.java
new file mode 100644
index 0000000..52c9338
--- /dev/null
+++ b/src/main/java/find_owners/PRED_check_owner_approval_2.java
@@ -0,0 +1,56 @@
+// Copyright (C) 2017 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 find_owners;
+
+import com.googlecode.prolog_cafe.exceptions.PrologException;
+import com.googlecode.prolog_cafe.lang.IntegerTerm;
+import com.googlecode.prolog_cafe.lang.Operation;
+import com.googlecode.prolog_cafe.lang.Predicate;
+import com.googlecode.prolog_cafe.lang.Prolog;
+import com.googlecode.prolog_cafe.lang.Term;
+import com.googlesource.gerrit.plugins.findowners.Checker;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * 'check_owner_approval'(+N, R) sets R to -1, 0, or 1,
+ * if owner approval is missing, unneeded, or satisfied.
+ */
+public class PRED_check_owner_approval_2 extends Predicate.P2 {
+
+  private static final Logger log =
+      LoggerFactory.getLogger(PRED_check_owner_approval_2.class);
+
+  public PRED_check_owner_approval_2(Term a1, Term a2, Operation n) {
+    arg1 = a1;
+    arg2 = a2;
+    cont = n;
+  }
+
+  /** Return engine.fail() only on exceptions. */
+  @Override
+  public Operation exec(Prolog engine) throws PrologException {
+    engine.cont = cont;
+    engine.setB0();
+    int n = 0; // minOwnerVoteLevel, set by Checker if (n <= 0)
+    if (arg1 instanceof IntegerTerm) {
+      n = ((IntegerTerm) arg1).intValue();
+    }
+    int result = Checker.findApproval(engine, n);
+    Term a2 = arg2.dereference();
+    IntegerTerm r = new IntegerTerm(result);
+    return a2.unify(r, engine.trail) ? cont : engine.fail();
+  }
+}
diff --git a/src/main/prolog/find_owners.pl b/src/main/prolog/find_owners.pl
new file mode 100644
index 0000000..6afd829
--- /dev/null
+++ b/src/main/prolog/find_owners.pl
@@ -0,0 +1,89 @@
+:- package find_owners.
+'$init'.
+
+%:- public add_may_label/2.
+%:- public remove_may_label/2.
+%:- public remove_need_label/2.
+%:- public submit_filter/2.
+%:- public submit_filter/3.
+%:- public submit_rule/1.
+%:- public submit_rule/2.
+
+% Default required vote value is set in Checker,
+% by reading the gerrit.config and project config files.
+submit_rule(S) :- submit_rule(S, 0).
+submit_filter(In, Out) :- submit_filter(In, Out, 0).
+
+submit_rule(S, N) :-
+  gerrit:default_submit(D),
+  submit_filter(D, S, N).
+
+submit_filter(In, Out, N) :-
+  add_owner_approval_label(In, Out, N), !.
+
+% Do nothing if add_owner_approval_label fails.
+submit_filter(X, X, _).
+
+owner_approved('Owner-Approved').
+
+owner_approval_missing('Owner-Review-Vote').
+
+owner_approval_label(X) :- owner_approved(X).
+owner_approval_label(X) :- owner_approval_missing(X).
+
+% Do nothing if X contains owner_approval_label.
+add_owner_approval_label(In, Out, _) :-
+  In =.. [submit|L],
+  has_owner_approval_label(L), !,
+  Out =.. [submit|L],
+  !.
+
+add_owner_approval_label(In, Out, N) :-
+  In =.. [submit|L],
+  % check_owner_approval(n, R) checks Code-Review votes with value >= n,
+  % then set R to -1/0/1 to mean owner approval is missing/unneeded/complete.
+  check_owner_approval(N, R), !,
+  create_owner_approval_label(R, Label),
+  Out =.. [submit|[Label|L]].
+
+create_owner_approval_label(0, label(X, may(_))) :-
+  owner_approved(X), !.
+create_owner_approval_label(N, label(X, ok(user(1)))) :-
+  N > 0, owner_approved(X), !.
+create_owner_approval_label(_, label(X, need(_))) :-
+  owner_approval_missing(X).
+
+has_owner_approval_label([label(X, _)|_]) :- owner_approval_label(X).
+has_owner_approval_label([_|L]) :- has_owner_approval_label(L).
+
+% Remove the grey label('Owner-Approval',may(_)) to avoid confusion.
+remove_may_label(In, Out) :-
+  In =.. [submit|L1],
+  owner_approved(N),
+  cleanup_label(label(N, may(_)), L1, L2),
+  Out =.. [submit|L2],
+  !.
+remove_may_label(X, X).
+
+% Remove label('Owner-Review-Vote',need(_)) for special changes.
+remove_need_label(In, Out) :-
+  In =.. [submit|L1],
+  owner_approval_missing(N),
+  cleanup_label(label(N, need(_)), L1, L2),
+  Out =.. [submit|L2],
+  !.
+remove_need_label(X, X).
+
+cleanup_label(_, [], []).
+cleanup_label(N, [X|L1], L2) :- N = X, !, cleanup_label(N, L1, L2).
+cleanup_label(N, [X|L1], [X|L2]) :- cleanup_label(N, L1, L2).
+
+% Add label('Owner-Approval',may(_)) to skip owner approval check.
+add_may_label(In, Out) :-
+  remove_may_label(In, Tmp1),
+  remove_need_label(Tmp1, Tmp2),
+  Tmp2 =.. [submit|L],
+  owner_approved(N),
+  Out =.. [submit|[label(N, may(_))|L]],
+  !.
+add_may_label(X, X).
diff --git a/src/main/resources/static/find-owners.js b/src/main/resources/static/find-owners.js
new file mode 100644
index 0000000..2e2c17c
--- /dev/null
+++ b/src/main/resources/static/find-owners.js
@@ -0,0 +1,355 @@
+// Copyright (C) 2017 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.
+
+Gerrit.install(function(self) {
+  function onFindOwners(c) {
+    const HTML_ALL_HAVE_OWNER_APPROVAL =
+        '<b>All files have owner approval.</b><br>';
+    const HTML_BULLET = '<small>&#x2605;</small>'; // a Black Star
+    const HTML_IS_EXEMPTED =
+        '<b>This commit is exempted from owner approval.</b><br>';
+    const HTML_NEED_REVIEWER_HEADER =
+        '<hr><b>Files without owner reviewer:</b><br>';
+    const HTML_NEED_APPROVAL_HEADER =
+        '<hr><b>Files without Code-Review vote from an owner:</b><br>';
+    const HTML_NO_OWNER =
+        '<b>No owner was found for changed files.</b><br>';
+    const HTML_OWNERS_HEADER = '<hr><b>Owners in alphabetical order:</b><br>';
+    const HTML_SELECT_REVIEWERS =
+        '<b>Check the box before owner names to select reviewers, ' +
+        'then click the "Apply" button.' +
+        '</b><br><small>Each file needs at least one owner. ' +
+        'Owners listed after a file are ordered by their importance. ' +
+        '(Or declare "<b><span style="font-size:80%;">' +
+        'Exempt-From-Owner-Approval:</span></b> ' +
+        '<i>reasons...</i>" in the Commit Message.)</small><br>';
+
+    const APPLY_BUTTON_ID = 'FindOwners:Apply';
+    const CHECKBOX_ID = 'FindOwners:CheckBox';
+    const HEADER_DIV_ID = 'FindOwners:Header';
+    const OWNERS_DIV_ID = 'FindOwners:Owners';
+    const NEED_APPROVAL_DIV_ID = 'FindOwners:NeedApproval';
+    const NEED_REVIEWER_DIV_ID = 'FindOwners:NeedReviewer';
+
+    // Aliases to values in the context.
+    const branch = c.change.branch;
+    const changeId = c.change._number;
+    const message = c.revision.commit.message;
+    const project = c.change.project;
+
+    var reviewerId = {}; // map from a reviewer's email to account id.
+    var reviewerVote = {}; // map from a reviewer's email to Code-Review vote.
+
+    // addList and removeList are used only under applySelections.
+    var addList = []; // remain emails to add to reviewers
+    var removeList = []; // remain emails to remove from reviewers
+    var needRefresh = false; // true if to refresh after checkAddRemoveLists
+
+    function getElement(id) {
+      return document.getElementById(id);
+    }
+    function getReviewers(change, callBack) {
+      Gerrit.get('changes/' + change + '/reviewers', callBack);
+    }
+    function setupReviewersMap(reviewerList) {
+      reviewerId = {};
+      reviewerVote = {};
+      reviewerList.forEach(function(reviewer) {
+        reviewerId[reviewer.email] = reviewer._account_id;
+        reviewerVote[reviewer.email] =
+            parseInt(reviewer.approvals['Code-Review']);
+        // The 'Code-Review' values could be " 0", "+1", "-1", "+2", etc.
+      });
+    }
+    function checkAddRemoveLists() {
+      // Gerrit.post and delete are asynchronous.
+      // Do one at a time, with checkAddRemoveLists as callBack.
+      for (var i = 0; i < addList.length; i++) {
+        var email = addList[i];
+        if (!(email in reviewerId)) {
+          addList = addList.slice(i + 1, addList.length);
+          // A post request can fail if given reviewer email is invalid.
+          // Gerrit core UI shows the error dialog and does not provide
+          // a way for plugins to handle the error yet.
+          needRefresh = true;
+          Gerrit.post('changes/' + changeId + '/reviewers',
+                      {'reviewer': email},
+                      checkAddRemoveLists);
+          return;
+        }
+      }
+      for (var i = 0; i < removeList.length; i++) {
+        var email = removeList[i];
+        if (email in reviewerId) {
+          removeList = removeList.slice(i + 1, removeList.length);
+          needRefresh = true;
+          Gerrit.delete('changes/' + changeId +
+                        '/reviewers/' + reviewerId[email],
+                        checkAddRemoveLists);
+          return;
+        }
+      }
+      c.hide();
+      if (needRefresh) {
+        needRefresh = false;
+        Gerrit.refresh();
+      }
+      callServer(showFindOwnersResults);
+    }
+    function applyGetReviewers(reviewerList) {
+      setupReviewersMap(reviewerList);
+      checkAddRemoveLists(); // update and pop up window at the end
+    }
+    function hasOwnerReviewer(reviewers, owners) {
+      return owners.some(function(owner) {
+        return (owner in reviewers || owner == '*');
+      });
+    }
+    function hasOwnerApproval(votes, minVoteLevel, owners) {
+      var foundApproval = false;
+      for (var j = 0; j < owners.length; j++) {
+        if (owners[j] in votes) {
+          var v = votes[owners[j]];
+          if (v < 0) {
+            return false; // cannot have any negative vote
+          }
+          // TODO: do not count if owners[j] is the patch committer.
+          foundApproval |= v >= minVoteLevel;
+        }
+      }
+      return foundApproval;
+    }
+    function isExemptedFromOwnerApproval() {
+      return message.match(/(Exempted|Exempt)-From-Owner-Approval:/);
+    }
+    function showDiv(div, text) {
+      div.style.display = 'inline';
+      div.innerHTML = text;
+    }
+    function strElement(s) {
+      var e = document.createElement('span');
+      e.innerHTML = s;
+      return e;
+    }
+    function showJsonLines(args, key, obj) {
+      showBoldKeyValueLines(args, key, JSON.stringify(obj, null, 2));
+    }
+    function showBoldKeyValueLines(args, key, value) {
+      args.push(c.hr(), strElement('<b>' + key + '</b>:'), c.br());
+      value.split('\n').forEach(function(line) {
+        args.push(c.msg(line), c.br());
+      });
+    }
+    function showDebugMessages(result, args) {
+      function addKeyValue(key, value) {
+        args.push(strElement('<b>' + key + '</b>: ' + value + '<br>'));
+      }
+      args.push(c.hr());
+      addKeyValue('changeId', changeId);
+      addKeyValue('project', project);
+      addKeyValue('branch', branch);
+      addKeyValue('Gerrit.url', Gerrit.url());
+      addKeyValue('self.url', self.url());
+      showJsonLines(args, 'changeOwner', c.change.owner);
+      showBoldKeyValueLines(args, 'commit.message', message);
+      showJsonLines(args, 'Client reviewers Ids', reviewerId);
+      showJsonLines(args, 'Client reviewers Votes', reviewerVote);
+      Object.keys(result).forEach(function(k) {
+        showJsonLines(args, 'Server.' + k, result[k]);
+      });
+    }
+    function showFilesAndOwners(result, args) {
+      var sortedOwners = result.owners.map(
+          function(line) { return line.split(' ')[0]; });
+      var groups = {};
+      // group name ==> {needReviewer, needApproval, owners}
+      var groupSize = {};
+      // group name ==> number of files in group
+      var header = emptyDiv(HEADER_DIV_ID);
+      var needReviewerDiv = emptyDiv(NEED_REVIEWER_DIV_ID);
+      var needApprovalDiv = emptyDiv(NEED_APPROVAL_DIV_ID);
+      addApplyButton();
+      var ownersDiv = emptyDiv(OWNERS_DIV_ID);
+      var numCheckBoxes = 0;
+      var owner2boxes = {}; // owner name ==> array of checkbox id
+      var owner2email = {}; // owner name ==> email address
+
+      function addApplyButton() {
+        var apply = c.button('Apply', {onclick: doApplyButton});
+        apply.id = APPLY_BUTTON_ID;
+        apply.style.display = 'none';
+        args.push(apply);
+      }
+      function emptyDiv(id) {
+        var e = document.createElement('div');
+        e.id = id;
+        e.style.display = 'none';
+        args.push(e);
+        return e;
+      }
+      function doApplyButton() {
+        addList = [];
+        removeList = [];
+        // add each owner's email address to addList or removeList
+        Object.keys(owner2boxes).forEach(function(owner) {
+          (getElement(owner2boxes[owner][0]).checked ?
+              addList : removeList).push(owner2email[owner]);
+        });
+        getReviewers(changeId, applyGetReviewers);
+      }
+      function clickBox(event) {
+        var name = event.target.value;
+        var checked = event.target.checked;
+        var others = owner2boxes[name];
+        others.forEach(function(id) { getElement(id).checked = checked; });
+        getElement(APPLY_BUTTON_ID).style.display = 'inline';
+      }
+      function addGroupsToDiv(div, keys, title) {
+        if (keys.length <= 0) {
+          div.style.display = 'none';
+          return;
+        }
+        div.innerHTML = '';
+        div.style.display = 'inline';
+        div.appendChild(strElement(title));
+        function addOwner(ownerEmail) {
+          numCheckBoxes++;
+          var name = ownerEmail.replace(/@[^ ]*/g, '');
+          owner2email[name] = ownerEmail;
+          var id = CHECKBOX_ID + ':' + numCheckBoxes;
+          if (!(name in owner2boxes)) {
+            owner2boxes[name] = [];
+          }
+          owner2boxes[name].push(id);
+          var box = c.checkbox();
+          box.checked = (ownerEmail in reviewerId);
+          box.id = id;
+          box.value = name;
+          box.onclick = clickBox;
+          div.appendChild(strElement('&nbsp;&nbsp; '));
+          var nobr = document.createElement('nobr');
+          nobr.appendChild(box);
+          nobr.appendChild(strElement(name));
+          div.appendChild(nobr);
+        }
+        keys.forEach(function(key) {
+          var owners = groups[key].owners;
+          var numFiles = groupSize[key];
+          var item = HTML_BULLET + '&nbsp;<b>' + key + '</b>' +
+              ((numFiles > 1) ? (' (' + numFiles + ' files):') : ':');
+          var setOfOwners = new Set(owners.split(' '));
+          function add2list(list, email) {
+            if (setOfOwners.has(email)) {
+              list.push(email);
+            }
+            return list;
+          }
+          div.appendChild(strElement(item));
+          sortedOwners.reduce(add2list, []).forEach(addOwner);
+          div.appendChild(c.br());
+        });
+      }
+      function addOwnersDiv(div, title) {
+        div.innerHTML = '';
+        div.style.display = 'inline';
+        div.appendChild(strElement(title));
+        result.owners.sort().forEach(function(owner) {
+          var email = owner.split(' ')[0];
+          var vote = reviewerVote[email];
+          if ((email in reviewerVote) && vote != 0) {
+            email += ' <font color="' +
+                ((vote > 0) ? 'green">(+' : 'red">(') + vote + ')</font>';
+          }
+          div.appendChild(strElement('&nbsp;&nbsp;' + email + '<br>'));
+        });
+      }
+      function updateDivContent() {
+        var groupNeedReviewer = [];
+        var groupNeedApproval = [];
+        numCheckBoxes = 0;
+        owner2boxes = {};
+        Object.keys(groups).sort().forEach(function(key) {
+          var g = groups[key];
+          if (g.needReviewer) {
+            groupNeedReviewer.push(key);
+          } else if (g.needApproval) {
+            groupNeedApproval.push(key);
+          }
+        });
+        if (0 == groupNeedReviewer.length && 0 == groupNeedApproval.length) {
+          showDiv(header, HTML_ALL_HAVE_OWNER_APPROVAL);
+        } else {
+          showDiv(header, HTML_SELECT_REVIEWERS);
+          addGroupsToDiv(needReviewerDiv, groupNeedReviewer,
+                         HTML_NEED_REVIEWER_HEADER);
+          addGroupsToDiv(needApprovalDiv, groupNeedApproval,
+                         HTML_NEED_APPROVAL_HEADER);
+          addOwnersDiv(ownersDiv, HTML_OWNERS_HEADER);
+        }
+      }
+      function createGroups() {
+        var owners2group = {}; // owner list to group name
+        var minVoteLevel =
+            ('minOwnerVoteLevel' in result ?
+             result['minOwnerVoteLevel'] : 1);
+        Object.keys(result.file2owners).sort().forEach(function(name) {
+          var owners = result.file2owners[name];
+          var splitOwners = owners.split(' ');
+          if (owners in owners2group) {
+            groupSize[owners2group[owners]] += 1;
+          } else {
+            owners2group[owners] = name;
+            groupSize[name] = 1;
+            var needReviewer = !hasOwnerReviewer(reviewerId, splitOwners);
+            var needApproval = !needReviewer &&
+                !hasOwnerApproval(reviewerVote, minVoteLevel, splitOwners);
+            groups[name] = {
+              'needReviewer': needReviewer,
+              'needApproval': needApproval,
+              'owners': owners};
+          }
+        });
+      }
+      createGroups();
+      updateDivContent();
+    }
+    function showFindOwnersResults(result) {
+      function popupWindow(reviewerList) {
+        setupReviewersMap(reviewerList);
+        var args = [];
+        if (isExemptedFromOwnerApproval()) {
+          args.push(strElement(HTML_IS_EXEMPTED));
+        } else if (Object.keys(result.file2owners).length <= 0) {
+          args.push(strElement(HTML_NO_OWNER));
+        } else {
+          showFilesAndOwners(result, args);
+        }
+        if (result.addDebugMsg) {
+          showDebugMessages(result, args);
+        }
+        c.popup(c.div.apply(this, args));
+      }
+      getReviewers(changeId, popupWindow);
+    }
+    function callServer(callBack) {
+      // Use either the revision post API or plugin get API.
+      // Only pass changeId, let server get current patch set,
+      // project and branch info.
+      c.call({change: changeId}, showFindOwnersResults);
+      // self.get('change/' + changeId, showFindOwnersResults);
+    }
+    callServer(showFindOwnersResults);
+  }
+  self.onAction('revision', 'find-owners', onFindOwners);
+});
diff --git a/src/test/java/com/googlesource/gerrit/plugins/findowners/ActionTest.java b/src/test/java/com/googlesource/gerrit/plugins/findowners/ActionTest.java
new file mode 100644
index 0000000..373e48a
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/findowners/ActionTest.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2017 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.findowners;
+
+import static com.google.common.truth.Truth.assertThat;
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonObject;
+import com.googlesource.gerrit.plugins.findowners.Util.String2String;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Test Action class */
+public class ActionTest {
+  private MockedServer server;
+  private Action finder;
+  private Gson gs;
+
+  @Before
+  public void setUp() {
+    finder = new Action("http://mocked:8888/", null);
+    server = new MockedServer();
+    finder.setServer(server);
+    gs = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create();
+  }
+
+  @Test
+  public void getChangeDataTest() {
+    JsonObject obj = finder.getChangeData(23, new String2String());
+    // Write expected output as a more readable string literal,
+    // remove all ' ', then use '\'' for '\"' and ' ' for '\n'.
+    String expected = "{ 'minOwnerVoteLevel':1, 'addDebugMsg':true, "
+        + "'change':23, 'patchset':3, 'owner_revision':'', "
+        + "'dbgmsgs':{ 'user':'?', 'project':'projectA', "
+        + "'branch':'master', 'server':'http://mocked:8888/' }, "
+        + "'path2owners':{}, 'owner2paths':{}, 'file2owners':{}, "
+        + "'reviewers':[], 'owners':[], 'files':[ './README', "
+        + "'./d1/test.c', './d2/t.txt' ] }";
+    String result = gs.toJson(obj).replaceAll(" ", "");
+    expected = expected.replaceAll(" ", "\n");
+    expected = expected.replaceAll("'", "\"");
+    assertThat(result).isEqualTo(expected);
+  }
+
+  // TODO: test getChangeData with non-trivial parameters
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/findowners/CheckerTest.java b/src/test/java/com/googlesource/gerrit/plugins/findowners/CheckerTest.java
new file mode 100644
index 0000000..bd87a0e
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/findowners/CheckerTest.java
@@ -0,0 +1,119 @@
+// Copyright (C) 2017 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.findowners;
+
+import static com.google.common.truth.Truth.assertThat;
+import com.googlesource.gerrit.plugins.findowners.Util.String2Integer;
+import com.googlesource.gerrit.plugins.findowners.Util.StringSet;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Test Checker class */
+public class CheckerTest {
+  private MockedServer server;
+
+  @Before
+  public void setUp() {
+    server = new MockedServer();
+  }
+
+  @Test
+  public void findOwnersInVotesTest() {
+    Checker c = new Checker(server, 2);
+    StringSet owners = new StringSet();
+    String2Integer votes = new String2Integer();
+    // no owner, default is false.
+    assertThat(c.findOwnersInVotes(owners, votes)).isEqualTo(false);
+    // has owner, no vote, default is false.
+    owners.add("xyz@google.com");
+    assertThat(c.findOwnersInVotes(owners, votes)).isEqualTo(false);
+    // has "*" owner, no owner vote is needed
+    owners.add("*");
+    assertThat(c.findOwnersInVotes(owners, votes)).isEqualTo(true);
+    // again, no owner means no
+    owners = new StringSet();
+    assertThat(c.findOwnersInVotes(owners, votes)).isEqualTo(false);
+    // two owners, but only +1
+    owners.add("abc@google.com");
+    owners.add("xyz@google.com");
+    votes.put("xyz@google.com", 1);
+    assertThat(c.findOwnersInVotes(owners, votes)).isEqualTo(false);
+    // one owner +2 vote is enough
+    votes.put("xyz@google.com", 2);
+    assertThat(c.findOwnersInVotes(owners, votes)).isEqualTo(true);
+    // two +1 votes is not the same as one +2
+    votes.put("abc@google.com", 1);
+    votes.put("xyz@google.com", 1);
+    assertThat(c.findOwnersInVotes(owners, votes)).isEqualTo(false);
+    votes.put("xyz@google.com", 2);
+    assertThat(c.findOwnersInVotes(owners, votes)).isEqualTo(true);
+    // one owner -1 is a veto.
+    votes.put("abc@google.com", -1);
+    assertThat(c.findOwnersInVotes(owners, votes)).isEqualTo(false);
+  }
+
+  void addOwnersInfo(MockedOwnersDb db) {
+    String[] emails = {"abc@google.com", "xyz@google.com"};
+    StringSet dirs = new StringSet();
+    StringSet owners = new StringSet();
+    dirs.add("./d1");
+    for (String e : emails) {
+      db.owner2Paths.put(e, dirs);
+      owners.add(e);
+    }
+    db.mockedFile2Owners.put("./d1/f1.c", owners);
+  }
+
+  @Test
+  public void findApprovalOwnersDbTest() {
+    Checker c = new Checker(server, 2);
+    MockedOwnersDb db = new MockedOwnersDb();
+    assertThat(c.findApproval(db)).isEqualTo(0); // no owners info
+    addOwnersInfo(db); // add one file and two owners
+    assertThat(db.getNumOwners()).isEqualTo(2);
+    assertThat(db.findOwners(null).size()).isEqualTo(1);
+    assertThat(db.findOwners(null).get("f1")).isEqualTo(null);
+    assertThat(db.findOwners(null).get("./d1/f1.c").size()).isEqualTo(2);
+    assertThat(c.findApproval(db)).isEqualTo(-1);
+    server.votes.put("abc@google.com", 1);
+    // sever has minOwnerVoteLevel 1, but checker requires 2.
+    assertThat(server.getMinOwnerVoteLevel()).isEqualTo(1);
+    assertThat(c.findApproval(db)).isEqualTo(-1); // vote 1 is not enough
+    c = new Checker(server, 1);
+    assertThat(c.findApproval(db)).isEqualTo(1);
+    server.votes.put("xyz@google.com", -1);
+    assertThat(c.findApproval(db)).isEqualTo(-1); // an owner's veto
+  }
+
+  @Test
+  public void findApprovalTest() {
+    Checker c = new Checker(server, 1);
+    // default mocked, not exempted from owner approval
+    assertThat(server.isExemptFromOwnerApproval()).isEqualTo(false);
+    // not exempted, but no owner in mocked OwnersDb
+    assertThat(c.findApproval()).isEqualTo(0);
+    server.exemptFromOwnerApproval = true;
+    assertThat(server.isExemptFromOwnerApproval()).isEqualTo(true);
+    // exempted, no owner, should be 0, not 1 or -1.
+    assertThat(c.findApproval()).isEqualTo(0);
+    server.exemptFromOwnerApproval = false;
+    // add mocked owners, no vote, should be -1, not approved.
+    addOwnersInfo(server.ownersDb);
+    assertThat(c.findApproval()).isEqualTo(-1);
+    // add vote, should be approved now.
+    server.votes.put("abc@google.com", 1);
+    assertThat(c.findApproval()).isEqualTo(1);
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/findowners/MockedOwnersDb.java b/src/test/java/com/googlesource/gerrit/plugins/findowners/MockedOwnersDb.java
new file mode 100644
index 0000000..fd24a9c
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/findowners/MockedOwnersDb.java
@@ -0,0 +1,71 @@
+// Copyright (C) 2017 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.findowners;
+
+import static com.google.common.truth.Truth.assertThat;
+import com.googlesource.gerrit.plugins.findowners.Util.String2StringSet;
+import com.googlesource.gerrit.plugins.findowners.Util.StringSet;
+import java.util.Collection;
+import org.junit.Test;
+
+/** Mocked OwnersDb to test Parser class */
+public class MockedOwnersDb extends OwnersDb {
+  private String savedData;
+  String2StringSet mockedFile2Owners;
+
+  public MockedOwnersDb() {
+    super(null);
+    resetData();
+    mockedFile2Owners = new String2StringSet();
+  }
+
+  void resetData() {
+    savedData = "";
+    stopLooking = new StringSet();
+  }
+
+  void appendSavedData(String s) {
+    savedData += s;
+  }
+
+  String getSavedData() {
+    return savedData;
+  }
+
+  StringSet getStopLooking() {
+    return stopLooking;
+  }
+
+  @Override
+  void addOwnerPathPair(String s1, String s2) {
+    savedData += "s1:" + s1 + "\ns2:" + s2 + "\n";
+  }
+
+  @Override
+  String2StringSet findOwners(Collection<String> files) {
+    return mockedFile2Owners;
+  }
+
+  @Test
+  public void defaultTest() {
+    // Trivial test of default OwnersDb members.
+    assertThat(revision).isEqualTo("");
+    assertThat(dir2Globs.size()).isEqualTo(0);
+    assertThat(owner2Paths.size()).isEqualTo(0);
+    assertThat(path2Owners.size()).isEqualTo(0);
+    assertThat(readDirs.size()).isEqualTo(0);
+    assertThat(stopLooking.size()).isEqualTo(0);
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/findowners/MockedServer.java b/src/test/java/com/googlesource/gerrit/plugins/findowners/MockedServer.java
new file mode 100644
index 0000000..da5df7e
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/findowners/MockedServer.java
@@ -0,0 +1,154 @@
+// Copyright (C) 2017 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.findowners;
+
+import static com.google.common.truth.Truth.assertThat;
+import com.google.gerrit.reviewdb.client.Change.Status;
+import com.google.gerrit.server.change.RevisionResource;
+import com.google.gson.JsonArray;
+import com.googlesource.gerrit.plugins.findowners.Util.String2Integer;
+import com.googlesource.gerrit.plugins.findowners.Util.String2String;
+import com.googlesource.gerrit.plugins.findowners.Util.StringSet;
+import java.util.Collection;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Test;
+
+/** Mocked server to test Action, Checker, OwnersDb classes. */
+public class MockedServer extends Server {
+
+  int minOwnerVoteLevel;
+  boolean addDebugMsg;
+  boolean traceServer;
+  boolean reportSyntaxError;
+  boolean exemptFromOwnerApproval;
+  StringSet changedFiles;
+  String2Integer votes;
+  JsonArray reviewers;
+  MockedOwnersDb ownersDb;
+  Status status;
+  String branchRevision;
+  String2String dir2owners; // map from a directory path to OWNERS content
+
+  public MockedServer() {
+    change = 12;
+    patchset = 3;
+    project = "projectA";
+    branch = "master";
+    error = null;
+    minOwnerVoteLevel = 1;
+    addDebugMsg = true;
+    traceServer = true;
+    reportSyntaxError = true;
+    exemptFromOwnerApproval = false;
+    changedFiles = new StringSet();
+    String[] sampleFiles = {"./README", "./d1/test.c", "./d2/t.txt"};
+    for (String file : sampleFiles) {
+      changedFiles.add(file);
+    }
+    votes = new String2Integer();
+    reviewers = new JsonArray();
+    ownersDb = new MockedOwnersDb();
+    status = Status.NEW;
+    branchRevision = "13579abcdef";
+    dir2owners = new String2String();
+  }
+
+  @Override
+  int getMinOwnerVoteLevel() {
+    return minOwnerVoteLevel;
+  }
+
+  @Override
+  boolean getAddDebugMsg() {
+    return addDebugMsg;
+  }
+
+  @Override
+  boolean traceServerMsg() {
+    return traceServer;
+  }
+
+  @Override
+  boolean getReportSyntaxError() {
+    return reportSyntaxError;
+  }
+
+  @Override
+  boolean isExemptFromOwnerApproval() {
+    return exemptFromOwnerApproval;
+  }
+
+  @Override
+  void setChangeId(String url, int change) {}
+
+  @Override
+  String setPatchId(String patchsetNum) {
+    return null;
+  }
+
+  @Override
+  Collection<String> getChangedFiles() {
+    return changedFiles;
+  }
+
+  @Override
+  String2Integer getVotes() {
+    return votes;
+  }
+
+  @Override
+  JsonArray getReviewers() {
+    return reviewers;
+  }
+
+  @Override
+  Status getStatus(RevisionResource resource) {
+    return status;
+  }
+
+  @Override
+  String getOWNERS(String dir, Repository repository, String url,
+                   String project, String branch) {
+    String content = dir2owners.get(dir);
+    return (null == content ? "" : content);
+  }
+
+  @Override
+  String getBranchRevision(Repository repository, String url,
+                            String project, String branch) {
+    return branchRevision;
+  }
+
+  @Override
+  OwnersDb getCachedOwnersDb() {
+    return ownersDb;
+  }
+
+  @Test
+  public void genDebugMsgTest() {
+    // Important because real server.traceServerMsg() is usually false.
+    String expected =
+        "\n## change=12, patchset=3, project=projectA, branch=master"
+        + "\n## changedFiles=" + changedFiles
+        + "\nnumOwners=0, minVoteLevel=1"
+        + ", approvals=" + getVotes();
+    assertThat(genDebugMsg(ownersDb)).isEqualTo(expected);
+    url = "http://localhost:8081/";
+    String expected2 = "\n## url=" + url + expected;
+    assertThat(genDebugMsg(ownersDb)).isEqualTo(expected2);
+  }
+
+  // TODO: use a mocked Repository to test getRepositoryFile
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/findowners/OwnerWeightsTest.java b/src/test/java/com/googlesource/gerrit/plugins/findowners/OwnerWeightsTest.java
new file mode 100644
index 0000000..ef28a89
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/findowners/OwnerWeightsTest.java
@@ -0,0 +1,90 @@
+// Copyright (C) 2017 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.findowners;
+
+import static com.google.common.truth.Truth.assertThat;
+import com.googlesource.gerrit.plugins.findowners.OwnerWeights.WeightComparator;
+import com.googlesource.gerrit.plugins.findowners.Util.Owner2Weights;
+import java.util.List;
+import org.junit.Test;
+
+/** Test OwnerWeights class */
+public class OwnerWeightsTest {
+
+  private OwnerWeights createOwnerWeights(int[] counts) {
+    OwnerWeights obj = new OwnerWeights();
+    for (int i = 0; i < counts.length; i++) {
+      for (int j = 0; j < counts[i]; j++) {
+        obj.addFile("f" + i + "_" + j, i + 1);
+      }
+    }
+    return obj;
+  }
+
+  @Test
+  public void addFileTest() {
+    OwnerWeights obj = new OwnerWeights();
+    assertThat(obj.encodeLevelCounts()).isEqualTo("[0+0+0]");
+    obj = new OwnerWeights("tmp", 0);
+    assertThat(obj.encodeLevelCounts()).isEqualTo("[1+0+0]");
+    obj = new OwnerWeights("tmp", 1);
+    assertThat(obj.encodeLevelCounts()).isEqualTo("[1+0+0]");
+    obj.addFile("tmp", 2);
+    assertThat(obj.encodeLevelCounts()).isEqualTo("[1+0+0]");
+    obj.addFile("tmp2", 2);
+    assertThat(obj.encodeLevelCounts()).isEqualTo("[1+1+0]");
+    obj.addFile("tmp3", 3);
+    assertThat(obj.encodeLevelCounts()).isEqualTo("[1+1+1]");
+    obj.addFile("tmp4", 4);
+    assertThat(obj.encodeLevelCounts()).isEqualTo("[1+1+2]");
+  }
+
+  @Test
+  public void sortKeysTest() {
+    int[] c000 = {0, 0, 0};
+    int[] c021 = {0, 2, 1};
+    int[] c023 = {0, 2, 1, 1, 1};
+    int[] c111 = {1, 1, 1};
+    OwnerWeights objX1 = createOwnerWeights(c000);
+    OwnerWeights objX2 = createOwnerWeights(c021);
+    OwnerWeights objX3 = createOwnerWeights(c023);
+    OwnerWeights objX4 = createOwnerWeights(c111);
+    assertThat(objX1.encodeLevelCounts()).isEqualTo("[0+0+0]");
+    assertThat(objX2.encodeLevelCounts()).isEqualTo("[0+2+1]");
+    assertThat(objX3.encodeLevelCounts()).isEqualTo("[0+2+3]");
+    assertThat(objX4.encodeLevelCounts()).isEqualTo("[1+1+1]");
+    Owner2Weights map = new Owner2Weights();
+    map.put("objX1", objX1);
+    map.put("objX2", objX2);
+    map.put("objX3", objX3);
+    map.put("objX4", objX4);
+    map.put("objX0", objX4);
+    List<String> keys = OwnerWeights.sortKeys(map);
+    assertThat(keys.get(0)).isEqualTo("objX0");
+    assertThat(keys.get(1)).isEqualTo("objX4");
+    assertThat(keys.get(2)).isEqualTo("objX3");
+    assertThat(keys.get(3)).isEqualTo("objX2");
+    assertThat(keys.get(4)).isEqualTo("objX1");
+    WeightComparator comp = new WeightComparator(map);
+    // comp.compare(A,B) < 0, if A has order before B
+    // comp.compare(A,B) > 0, if A has order after B
+    assertThat(comp.compare("objX1", "objX2") > 0).isEqualTo(true);
+    assertThat(comp.compare("objX3", "objX2") < 0).isEqualTo(true);
+    assertThat(comp.compare("objX3", "objX4") > 0).isEqualTo(true);
+    assertThat(comp.compare("objX3", "objX3")).isEqualTo(0);
+    assertThat(comp.compare("objX4", "objX0") > 0).isEqualTo(true);
+    assertThat(comp.compare("objX0", "objX4") < 0).isEqualTo(true);
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/findowners/OwnersDbTest.java b/src/test/java/com/googlesource/gerrit/plugins/findowners/OwnersDbTest.java
new file mode 100644
index 0000000..df3c7fd
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/findowners/OwnersDbTest.java
@@ -0,0 +1,50 @@
+// Copyright (C) 2017 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.findowners;
+
+import static com.google.common.truth.Truth.assertThat;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Test OwnersDb class */
+public class OwnersDbTest {
+  private MockedServer server;
+
+  @Before
+  public void setUp() {
+    server = new MockedServer();
+  }
+
+  @Test
+  public void ctorTest() {
+    // TODO: test getNumOwners, etc.
+  }
+
+  @Test
+  public void addOwnerPathPairTest() {
+    // TODO: test addOwnerPathPair
+  }
+
+  @Test
+  public void addFileTest() {
+    // TODO: test addFile
+    assertThat(1 + 1).isEqualTo(2);
+  }
+
+  @Test
+  public void findOwnersTest() {
+    // TODO: test findOwners
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/findowners/ParserTest.java b/src/test/java/com/googlesource/gerrit/plugins/findowners/ParserTest.java
new file mode 100644
index 0000000..4488512
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/findowners/ParserTest.java
@@ -0,0 +1,159 @@
+// Copyright (C) 2017 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.findowners;
+
+import static com.google.common.truth.Truth.assertThat;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Test Parser class */
+public class ParserTest {
+  private MockedOwnersDb db;
+
+  @Before
+  public void setUp() {
+    db = new MockedOwnersDb();
+  }
+
+  private String mockedTestDir() {
+    return "./d1/d2/";
+  }
+
+  private void testLine(String line) {
+    db.resetData();
+    String result = Parser.parseLine(db, mockedTestDir(), "OWNERS", line, 3);
+    db.appendSavedData((result != null) ? (result + line) : line);
+  }
+
+  private String testLineOwnerPath(String line, String s1) {
+    return testLineOwnerPath(line, s1, mockedTestDir());
+  }
+
+  private String testLineOwnerPath(String line, String s1, String s2) {
+    // expected db.savedData created by testLine(line)
+    // followed by call to addOwnerPathPair(s1, s2)
+    return "s1:" + s1 + "\ns2:" + s2 + "\n" + line;
+  }
+
+  private String testLineWarningMsg(String line) {
+    // expected warning message created by testLine(line)
+    return Parser.warningMsg("OWNERS", 3, "ignored", line);
+  }
+
+  private String testLineErrorMsg(String line) {
+    // expected error message created by testLine(line)
+    return Parser.errorMsg("OWNERS", 3, "ignored unknown line", line);
+  }
+
+  @Test
+  public void badLineTest() {
+    String[] lines = {"actor", "a@b@c", "**", "per-files *.gyp",
+                      "a@b.com@c.com #..."};
+    for (String s : lines) {
+      testLine(s);
+      String expected = testLineErrorMsg(s) + s;
+      assertThat(db.getSavedData()).isEqualTo(expected);
+    }
+  }
+
+  @Test
+  public void commentLineTest() {
+    String[] lines = {"", "   ", "# comment #data", "#any", "  # comment"};
+    for (String s : lines) {
+      testLine(s);
+      assertThat(db.getSavedData()).isEqualTo(s);
+    }
+  }
+
+  @Test
+  public void emailLineTest() {
+    String[] lines = {"a_b-c3@google.com", "  x.y.z@gmail.com # comment",
+                      "*", "  *  # any user"};
+    String[] emails = {"a_b-c3@google.com", "x.y.z@gmail.com", "*", "*"};
+    for (int i = 0; i < lines.length; i++) {
+      testLine(lines[i]);
+      String expected = testLineOwnerPath(lines[i], emails[i]);
+      assertThat(db.getSavedData()).isEqualTo(expected);
+    }
+  }
+
+  @Test
+  public void fileLineTest() {
+    // file: directive is not implemented yet.
+    String[] lines = {"file://owners", " file: //d1/owner", "file:owner #"};
+    for (String s : lines) {
+      testLine(s);
+      String expected = testLineWarningMsg(s) + s;
+      assertThat(db.getSavedData()).isEqualTo(expected);
+    }
+  }
+
+  @Test
+  public void noParentLineTest() {
+    String[] lines = {"set noparent", "  set  noparent",
+                      "set noparent # comment"};
+    for (String line : lines) {
+      db.resetData();
+      assertThat(db.stopLooking.size()).isEqualTo(0);
+      testLine(line);
+      assertThat(db.stopLooking.size()).isEqualTo(1);
+      assertThat(db.stopLooking.contains(mockedTestDir())).isEqualTo(true);
+      assertThat(db.getSavedData()).isEqualTo(line);
+    }
+  }
+
+  @Test
+  public void perFileGoodDirectiveTest() {
+    String[] directives = {"abc@google.com#comment",
+                           "  *# comment",
+                           "  xyz@gmail.com # comment"};
+    String[] emails = {"abc@google.com", "*", "xyz@gmail.com"};
+    for (int i = 0; i < directives.length; i++) {
+      String line = "per-file *test*.java=" + directives[i];
+      testLine(line);
+      String expected =
+          testLineOwnerPath(line, emails[i], mockedTestDir() + "*test*.java");
+      assertThat(db.getSavedData()).isEqualTo(expected);
+    }
+  }
+
+  @Test
+  public void perFileBadDirectiveTest() {
+    // TODO: test "set noparent" after perf-file.
+    String[] directives =
+        {"file://OWNERS", " ** ", "a b@c .co", "a@b@c  #com", "a.<b>@zc#"};
+    String[] errors =
+        {"file://OWNERS", "**", "a", "a@b@c", "a.<b>@zc"};
+    for (int i = 0; i < directives.length; i++) {
+      String line = "per-file *test*.c=" + directives[i];
+      testLine(line);
+      String expected = testLineErrorMsg(errors[i]) + line;
+      assertThat(db.getSavedData()).isEqualTo(expected);
+    }
+  }
+
+  @Test
+  public void errorMsgTest() {
+    String file = "./OWNERS";
+    int n = 5;
+    String msg = "error X";
+    String line = "a@@a";
+    String location = file + ":" + n + ": " + msg + ": [" + line + "]";
+    String error = "Error: " + location;
+    String warning = "Warning: " + location;
+    assertThat(Parser.errorMsg(file, n, msg, line)).isEqualTo(error);
+    assertThat(Parser.warningMsg(file, n, msg, line)).isEqualTo(warning);
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/findowners/UtilTest.java b/src/test/java/com/googlesource/gerrit/plugins/findowners/UtilTest.java
new file mode 100644
index 0000000..96b29a1
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/findowners/UtilTest.java
@@ -0,0 +1,179 @@
+// Copyright (C) 2017 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.findowners;
+
+import static com.google.common.truth.Truth.assertThat;
+import com.googlesource.gerrit.plugins.findowners.Util.Owner2Weights;
+import com.googlesource.gerrit.plugins.findowners.Util.String2Integer;
+import com.googlesource.gerrit.plugins.findowners.Util.String2String;
+import com.googlesource.gerrit.plugins.findowners.Util.String2StringSet;
+import com.googlesource.gerrit.plugins.findowners.Util.StringSet;
+import org.junit.Test;
+
+/** Test Util class */
+public class UtilTest {
+
+  @Test
+  public void getOwner2WeightsTest() {
+    Owner2Weights m = new Owner2Weights();
+    assertThat(m.size()).isEqualTo(0);
+    assertThat(m.get("s1")).isEqualTo(null);
+    OwnerWeights v1 = new OwnerWeights();
+    OwnerWeights v2 = new OwnerWeights();
+    m.put("s1", v1);
+    assertThat(m.get("s1")).isEqualTo(v1);
+    // compare OwnerWeights by reference
+    assertThat(m.get("s1")).isNotEqualTo(v2);
+    assertThat(m.get("s2")).isEqualTo(null);
+    assertThat(m.size()).isEqualTo(1);
+  }
+
+  @Test
+  public void getString2IntegerTest() {
+    String2Integer m = new String2Integer();
+    assertThat(m.size()).isEqualTo(0);
+    assertThat(m.get("s1")).isEqualTo(null);
+    Integer v1 = 3;
+    Integer v2 = 3;
+    m.put("s1", v1);
+    assertThat(m.get("s1")).isEqualTo(v1);
+    // compare Integer by value
+    assertThat(m.get("s1")).isEqualTo(v2);
+    assertThat(m.get("s2")).isEqualTo(null);
+    assertThat(m.size()).isEqualTo(1);
+  }
+
+  @Test
+  public void getString2StringTest() {
+    String2String m = new String2String();
+    assertThat(m.size()).isEqualTo(0);
+    assertThat(m.get("s1")).isEqualTo(null);
+    String v1 = "x";
+    String v2 = "x";
+    m.put("s1", v1);
+    assertThat(m.get("s1")).isEqualTo(v1);
+    // compare String by value
+    assertThat(m.get("s1")).isEqualTo(v2);
+    assertThat(m.get("s2")).isEqualTo(null);
+    assertThat(m.size()).isEqualTo(1);
+  }
+
+  @Test
+  public void getString2StringSetTest() {
+    String2StringSet m = new String2StringSet();
+    assertThat(m.size()).isEqualTo(0);
+    assertThat(m.get("s1")).isEqualTo(null);
+    StringSet v1 = new StringSet();
+    StringSet v2 = new StringSet();
+    assertThat(v1.size()).isEqualTo(0);
+    v1.add("x");
+    v1.add("y");
+    v2.add("y");
+    v2.add("x");
+    assertThat(v1.size()).isEqualTo(2);
+    m.put("s1", v1);
+    assertThat(m.get("s1")).isEqualTo(v1);
+    // compare StringSet by value
+    assertThat(m.get("s1")).isEqualTo(v2);
+    assertThat(m.get("s2")).isEqualTo(null);
+    assertThat(m.size()).isEqualTo(1);
+  }
+
+  @Test
+  public void addStringSetTest() {
+    StringSet s = new StringSet();
+    assertThat(s.size()).isEqualTo(0);
+    s.add("s1");
+    assertThat(s.contains("s1")).isEqualTo(true);
+    assertThat(s.contains("s2")).isEqualTo(false);
+    assertThat(s.size()).isEqualTo(1);
+  }
+
+  @Test
+  public void normalizeURLTest() {
+    String otherURL = "other:///something///else";
+    String normalOtherURL = "other://something///else";
+    String normalURL = "http://www.google.com:8080/plugins";
+    String badURL = "http:///www.google.com:8080/plugins";
+    String localURL = "http://localhost:8080/plugins";
+    String badLocalURL = "http:///localhost:8080/plugins";
+    // Allow other URL protocols, although we might need only http for now.
+    assertThat(Util.normalizeURL(otherURL)).isEqualTo(normalOtherURL);
+    assertThat(Util.normalizeURL(normalURL)).isEqualTo(normalURL);
+    assertThat(Util.normalizeURL(badURL)).isEqualTo(normalURL);
+    assertThat(Util.normalizeURL(localURL)).isEqualTo(localURL);
+    assertThat(Util.normalizeURL(badLocalURL)).isEqualTo(localURL);
+  }
+
+  @Test
+  public void stripMagicPrefixTest() {
+    assertThat(Util.stripMagicPrefix("abc\nxyz\n")).isEqualTo("abc\nxyz\n");
+    assertThat(Util.stripMagicPrefix(")]}'\nxyz\n")).isEqualTo("xyz\n");
+    assertThat(Util.stripMagicPrefix(")]}'xyz\n")).isEqualTo(")]}'xyz\n");
+    assertThat(Util.stripMagicPrefix(")]}'\nxyz")).isEqualTo("xyz");
+  }
+
+  // TODO: use a mocked HTTP server to test:
+  //     getHTTP getHTTPJsonObject getHTTPJsonArray getHTTPBase64Content
+
+  @Test
+  public void getDirNameTest() {
+    String[] files = {"", "./d1/", "d1/d2/f1.c", "./d2/f2.c", "./d1" };
+    String[] dirs = {null, ".", "d1/d2", "./d2", "."};
+    for (int i = 0; i < files.length; i++) {
+       assertThat(Util.getDirName(files[i])).isEqualTo(dirs[i]);
+    }
+  }
+
+  @Test
+  public void normalizeFilePathTest() {
+    String[] files = {"", "./d1/", "d1/d2/f1.c", "d2/f2/", "d1" };
+    String[] results = {"./", "./d1/", "./d1/d2/f1.c", "./d2/f2/", "./d1"};
+    for (int i = 0; i < files.length; i++) {
+       assertThat(Util.normalizedFilePath(files[i])).isEqualTo(results[i]);
+    }
+  }
+
+  @Test
+  public void normalizedDirPathTest() {
+    String[] files = {"", "./d1/", "d1/d2/f1.c", "./d2/f2.c", "./d1" };
+    String[] dirs = {null, ".", "./d1/d2", "./d2", "."};
+    for (int i = 0; i < files.length; i++) {
+       assertThat(Util.normalizedDirPath(files[i])).isEqualTo(dirs[i]);
+    }
+  }
+
+  @Test
+  public void parseBooleanTest() {
+    String[] yesStrs = {"True", "1", "true", "TRUE", "yes"};
+    String[] noStrs = {"", "False", "0", "false", "FALSE", "no", "other"};
+    for (String s : yesStrs) {
+       assertThat(Util.parseBoolean(s)).isEqualTo(true);
+    }
+    for (String s : noStrs) {
+       assertThat(Util.parseBoolean(s)).isEqualTo(false);
+    }
+  }
+
+  @Test
+  public void sortTest() {
+    // TODO: test Util.sort
+  }
+
+  @Test
+  public void newJsonArrayFromStringSetTest() {
+    // TODO: test Uril.newJsonArrayFromStringSet
+  }
+}