Merge branch 'stable-2.14' into stable-2.15

* stable-2.14:
  Fix test dependencies

Change-Id: I2e02db7984cb105ae950e8ed8342d52a8e09acf9
diff --git a/BUILD b/BUILD
index 8cb3515..0371641 100644
--- a/BUILD
+++ b/BUILD
@@ -13,7 +13,7 @@
     srcs = glob(["src/main/prolog/*.pl"]),
     deps = [
         ":find-owners-lib",
-        "//gerrit-server/src/main/prolog:common",
+        "//gerrit-server:prolog-common",
     ],
 )
 
@@ -40,6 +40,7 @@
     # resources = glob(['src/test/resources/**/*']),
     tags = ["findowners"],
     deps = PLUGIN_DEPS + PLUGIN_TEST_DEPS + [
+        "@commons-io//jar",
         ":find-owners-lib",
         ":find-owners-prolog-rules",
     ],
diff --git a/src/main/java/com/googlesource/gerrit/plugins/findowners/Action.java b/src/main/java/com/googlesource/gerrit/plugins/findowners/Action.java
index 13a1cc9..d232382 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/findowners/Action.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/findowners/Action.java
@@ -27,6 +27,7 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.Emails;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.RevisionResource;
 import com.google.gerrit.server.config.PluginConfigFactory;
@@ -54,6 +55,7 @@
   private static final Logger log = LoggerFactory.getLogger(Action.class);
 
   private AccountCache accountCache;
+  private Emails emails;
   private ChangeData.Factory changeDataFactory;
   private GitRepositoryManager repoManager;
   private Provider<CurrentUser> userProvider;
@@ -72,11 +74,13 @@
       SchemaFactory<ReviewDb> reviewDbProvider,
       ChangeData.Factory changeDataFactory,
       AccountCache accountCache,
+      Emails emails,
       GitRepositoryManager repoManager) {
     this.userProvider = userProvider;
     this.reviewDbProvider = reviewDbProvider;
     this.changeDataFactory = changeDataFactory;
     this.accountCache = accountCache;
+    this.emails = emails;
     this.repoManager = repoManager;
     Config.setVariables(pluginName, configFactory);
     Cache.getInstance(); // Create a single Cache.
@@ -139,7 +143,7 @@
         result.add(account.getPreferredEmail());
       }
     } catch (OrmException e) {
-      log.error("Exception", e);
+      log.error("Exception for " + Config.getChangeId(changeData), e);
       result = new ArrayList<>();
     }
     return result;
@@ -165,9 +169,9 @@
   /** REST API to return owners info of a change. */
   public Response<RestResult> getChangeData(
       Repository repository, Parameters params, ChangeData changeData)
-      throws OrmException, BadRequestException {
+      throws OrmException, BadRequestException, IOException {
     int patchset = getValidPatchsetNum(changeData, params.patchset);
-    OwnersDb db = Cache.getInstance().get(repository, changeData, patchset);
+    OwnersDb db = Cache.getInstance().get(accountCache, emails, repository, changeData, patchset);
     Collection<String> changedFiles = changeData.currentFilePaths();
     Map<String, Set<String>> file2Owners = db.findOwners(changedFiles);
 
@@ -180,6 +184,7 @@
       obj.dbgmsgs.user = getUserName();
       obj.dbgmsgs.project = changeData.change().getProject().get();
       obj.dbgmsgs.branch = changeData.change().getDest().get();
+      obj.dbgmsgs.errors = db.errors;
       obj.dbgmsgs.path2owners = Util.makeSortedMap(db.path2Owners);
       obj.dbgmsgs.owner2paths = Util.makeSortedMap(db.owner2Paths);
     }
@@ -194,10 +199,13 @@
   @Override
   public Description getDescription(RevisionResource resource) {
     Change change = resource.getChangeResource().getChange();
+    ChangeData changeData = null;
     try (ReviewDb reviewDb = reviewDbProvider.open()) {
-      ChangeData changeData = changeDataFactory.create(reviewDb, change);
+      changeData = changeDataFactory.create(reviewDb, change);
       if (changeData.change().getDest().get() == null) {
-        log.error("Cannot get branch of change: " + changeData.getId().get());
+        if (!Checker.isExemptFromOwnerApproval(changeData)) {
+          log.error("Cannot get branch of change: " + changeData.getId().get());
+        }
         return null; // no "Find Owners" button
       }
       Status status = resource.getChange().getStatus();
@@ -211,7 +219,7 @@
       if (needFindOwners && !Config.getAlwaysShowButton()) {
         needFindOwners = false; // Show button only if some owner is found.
         try (Repository repo = repoManager.openRepository(change.getProject())) {
-          OwnersDb db = Cache.getInstance().get(repo, changeData);
+          OwnersDb db = Cache.getInstance().get(accountCache, emails, repo, changeData);
           log.trace("getDescription db key = " + db.key);
           needFindOwners = db.getNumOwners() > 0;
         }
@@ -221,7 +229,7 @@
           .setTitle("Find owners to add to Reviewers list")
           .setVisible(needFindOwners);
     } catch (IOException | OrmException e) {
-      log.error("Exception", e);
+      log.error("Exception for " + Config.getChangeId(changeData), e);
       throw new IllegalStateException(e);
     }
   }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/findowners/Cache.java b/src/main/java/com/googlesource/gerrit/plugins/findowners/Cache.java
index 48f86e6..aa43896 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/findowners/Cache.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/findowners/Cache.java
@@ -18,8 +18,11 @@
 
 import com.google.common.cache.CacheBuilder;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.Emails;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
+import java.io.IOException;
 import java.util.Collection;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
@@ -85,29 +88,48 @@
   }
 
   /** Returns a cached or new OwnersDb, for the current patchset. */
-  OwnersDb get(Repository repo, ChangeData changeData) throws OrmException {
-    return get(repo, changeData, changeData.currentPatchSet().getId().get());
+  OwnersDb get(AccountCache accountCache, Emails emails, Repository repo, ChangeData changeData)
+      throws OrmException, IOException {
+    return get(accountCache, emails, repo, changeData, changeData.currentPatchSet().getId().get());
   }
 
   /** Returns a cached or new OwnersDb, for the specified patchset. */
-  OwnersDb get(Repository repository, ChangeData changeData, int patchset) throws OrmException {
+  OwnersDb get(
+      AccountCache accountCache,
+      Emails emails,
+      Repository repository,
+      ChangeData changeData,
+      int patchset)
+      throws OrmException, IOException {
     Project.NameKey project = changeData.change().getProject();
     String branch = changeData.change().getDest().get();
     String dbKey = Cache.makeKey(changeData.getId().get(), patchset, branch);
     // TODO: get changed files of the given patchset?
-    return get(dbKey, repository, project, branch, changeData.currentFilePaths());
+    return get(
+        accountCache,
+        emails,
+        dbKey,
+        repository,
+        changeData,
+        project,
+        branch,
+        changeData.currentFilePaths());
   }
 
   /** Returns a cached or new OwnersDb, for the specified branch and changed files. */
   OwnersDb get(
+      AccountCache accountCache,
+      Emails emails,
       String key,
       Repository repository,
+      ChangeData changeData,
       Project.NameKey project,
       String branch,
       Collection<String> files) {
     if (dbCache == null) { // Do not cache OwnersDb
       log.trace("Create new OwnersDb, key=" + key);
-      return new OwnersDb(key, repository, project, branch, files);
+      return new OwnersDb(
+          accountCache, emails, key, repository, changeData, project, branch, files);
     }
     try {
       log.trace("Get from cash " + dbCache + ", key=" + key + ", cache size=" + dbCache.size());
@@ -117,12 +139,14 @@
             @Override
             public OwnersDb call() {
               log.trace("Create new OwnersDb, key=" + key);
-              return new OwnersDb(key, repository, project, branch, files);
+              return new OwnersDb(
+                  accountCache, emails, key, repository, changeData, project, branch, files);
             }
           });
     } catch (ExecutionException e) {
-      log.error("Cache.get has exception: " + e);
-      return new OwnersDb(key, repository, project, branch, files);
+      log.error("Cache.get has exception for " + Config.getChangeId(changeData), e);
+      return new OwnersDb(
+          accountCache, emails, key, repository, changeData, project, branch, files);
     }
   }
 
diff --git a/src/main/java/com/googlesource/gerrit/plugins/findowners/Checker.java b/src/main/java/com/googlesource/gerrit/plugins/findowners/Checker.java
index 8cc92ac..ae163d5 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/findowners/Checker.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/findowners/Checker.java
@@ -14,12 +14,11 @@
 
 package com.googlesource.gerrit.plugins.findowners;
 
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change.Status;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
-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.account.AccountCache;
+import com.google.gerrit.server.account.Emails;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
 import com.googlecode.prolog_cafe.lang.Prolog;
@@ -50,20 +49,22 @@
   }
 
   /** Returns a map from reviewer email to vote value. */
-  static Map<String, Integer> getVotes(ChangeData changeData) throws OrmException {
-    ReviewDb db = changeData.db();
+  Map<String, Integer> getVotes(AccountCache accountCache, ChangeData changeData)
+      throws OrmException {
     Map<String, Integer> map = new HashMap<>();
-    AccountAccess ac = db.accounts();
     for (PatchSetApproval p : changeData.currentApprovals()) {
       if (p.getValue() != 0) {
-        Account.Id id = p.getAccountId();
-        try {
-          map.put(ac.get(id).getPreferredEmail(), Integer.valueOf(p.getValue()));
-        } catch (OrmException e) {
-          log.error("Cannot get email address of account id: " + id.get(), e);
-        }
+        map.put(
+            accountCache.get(p.getAccountId()).getAccount().getPreferredEmail(),
+            Integer.valueOf(p.getValue()));
       }
     }
+    // Give CL author a default minVoteLevel vote.
+    String author =
+        accountCache.get(changeData.change().getOwner()).getAccount().getPreferredEmail();
+    if (!map.containsKey(author) || map.get(author) == 0) {
+      map.put(author, minVoteLevel);
+    }
     return map;
   }
 
@@ -74,8 +75,6 @@
     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("*")) {
@@ -86,12 +85,12 @@
   }
 
   /** Returns 1 if owner approval is found, -1 if missing, 0 if unneeded. */
-  int findApproval(OwnersDb db) throws OrmException {
+  int findApproval(AccountCache accountCache, OwnersDb db) throws OrmException, IOException {
     Map<String, Set<String>> file2Owners = db.findOwners(changeData.currentFilePaths());
     if (file2Owners.size() == 0) { // do not need owner approval
       return 0;
     }
-    Map<String, Integer> votes = getVotes(changeData);
+    Map<String, Integer> votes = getVotes(accountCache, changeData);
     for (Set<String> owners : file2Owners.values()) {
       if (!findOwnersInVotes(owners, votes)) {
         return -1;
@@ -102,12 +101,15 @@
 
   /** Returns 1 if owner approval is found, -1 if missing, 0 if unneeded. */
   public static int findApproval(Prolog engine, int minVoteLevel) {
+    ChangeData changeData = null;
     try {
-      ChangeData changeData = StoredValues.CHANGE_DATA.get(engine);
+      changeData = StoredValues.CHANGE_DATA.get(engine);
+      AccountCache accountCache = StoredValues.ACCOUNT_CACHE.get(engine);
+      Emails emails = StoredValues.EMAILS.get(engine);
       Repository repository = StoredValues.REPOSITORY.get(engine);
-      return new Checker(repository, changeData, minVoteLevel).findApproval();
-    } catch (OrmException e) {
-      log.error("Exception", e);
+      return new Checker(repository, changeData, minVoteLevel).findApproval(accountCache, emails);
+    } catch (OrmException | IOException e) {
+      log.error("Exception for " + Config.getChangeId(changeData), e);
       return 0; // owner approval may or may not be required.
     }
   }
@@ -120,7 +122,7 @@
         return true;
       }
     } catch (IOException | OrmException e) {
-      log.error("Cannot get commit message", e);
+      log.error("Cannot get commit message for " + Config.getChangeId(changeData), e);
       return true; // exempt from owner approval due to lack of data
     }
     // Abandoned and merged changes do not need approval again.
@@ -128,13 +130,13 @@
     return (status == Status.ABANDONED || status == Status.MERGED);
   }
 
-  int findApproval() throws OrmException {
+  int findApproval(AccountCache accountCache, Emails emails) throws OrmException, IOException {
     if (isExemptFromOwnerApproval(changeData)) {
       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 = Cache.getInstance().get(repository, changeData);
+    OwnersDb db = Cache.getInstance().get(accountCache, emails, repository, changeData);
     if (db.getNumOwners() <= 0) {
       return 0;
     }
@@ -142,6 +144,6 @@
       minVoteLevel = Config.getMinOwnerVoteLevel(changeData);
     }
     log.trace("findApproval db key = " + db.key);
-    return findApproval(db);
+    return findApproval(accountCache, 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
index d7008b3..e450dfb 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/findowners/Config.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/findowners/Config.java
@@ -32,16 +32,15 @@
   static final String MAX_CACHE_AGE = "maxCacheAge"; // seconds to stay in cache
   static final String MAX_CACHE_SIZE = "maxCacheSize"; // number of OwnersDb in cache
   static final String MIN_OWNER_VOTE_LEVEL = "minOwnerVoteLevel"; // default +1
-  static final String OWNERS_FILE_NAME = "ownersFileName"; // default "OWNERS"
+  static final String OWNERS = "OWNERS"; // Default file name
+  static final String OWNERS_FILE_NAME = "ownersFileName"; // config key for file name
+  static final String REJECT_ERROR_IN_OWNERS = "rejectErrorInOwners"; // config key for validator
   static final String REPORT_SYNTAX_ERROR = "reportSyntaxError";
 
   // Name of plugin and namespace.
   static final String PLUGIN_NAME = "find-owners";
   static final String PROLOG_NAMESPACE = "find_owners";
 
-  // Default values.
-  private static final String DEFAULT_OWNERS_FILE_NAME = "OWNERS";
-
   // Global/plugin config parameters.
   private static PluginConfigFactory config = null;
   private static boolean addDebugMsg = false;
@@ -88,24 +87,35 @@
     return alwaysShowButton;
   }
 
-  static String getOwnersFileName(Project.NameKey project) {
+  static String getChangeId(ChangeData data) {
+    return data == null ? "(unknown change)" : ("change c/" + data.getId().get());
+  }
+
+  static String getOwnersFileName(Project.NameKey project, ChangeData c) {
     if (config != null && project != null) {
       try {
         String name =
             config
                 .getFromProjectConfigWithInheritance(project, PLUGIN_NAME)
-                .getString(OWNERS_FILE_NAME, DEFAULT_OWNERS_FILE_NAME);
+                .getString(OWNERS_FILE_NAME, OWNERS);
         if (name.trim().equals("")) {
           log.error(
-              "Project " + project.get() + " has wrong " + OWNERS_FILE_NAME + ": \"" + name + "\"");
-          return DEFAULT_OWNERS_FILE_NAME;
+              "Project "
+                  + project
+                  + " has wrong "
+                  + OWNERS_FILE_NAME
+                  + ": \""
+                  + name
+                  + "\" for "
+                  + getChangeId(c));
+          return OWNERS;
         }
         return name;
       } catch (NoSuchProjectException e) {
-        log.error("Cannot find project: " + project);
+        log.error("Cannot find project " + project + " for " + getChangeId(c), e);
       }
     }
-    return DEFAULT_OWNERS_FILE_NAME;
+    return OWNERS;
   }
 
   @VisibleForTesting
@@ -122,7 +132,7 @@
               .getFromProjectConfigWithInheritance(project, PLUGIN_NAME)
               .getInt(MIN_OWNER_VOTE_LEVEL, minOwnerVoteLevel);
     } catch (NoSuchProjectException e) {
-      log.error("Cannot find project: " + project);
+      log.error("Cannot find project " + project + " for " + getChangeId(changeData), e);
       return minOwnerVoteLevel;
     }
   }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/findowners/GetOwners.java b/src/main/java/com/googlesource/gerrit/plugins/findowners/GetOwners.java
index d4e45ef..fd60850 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/findowners/GetOwners.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/findowners/GetOwners.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.Emails;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -56,6 +57,7 @@
       SchemaFactory<ReviewDb> reviewDbProvider,
       ChangeData.Factory dataFactory,
       AccountCache accountCache,
+      Emails emails,
       GitRepositoryManager repoManager) {
     this.action =
         new Action(
@@ -65,6 +67,7 @@
             reviewDbProvider,
             dataFactory,
             accountCache,
+            emails,
             repoManager);
   }
 
diff --git a/src/main/java/com/googlesource/gerrit/plugins/findowners/Module.java b/src/main/java/com/googlesource/gerrit/plugins/findowners/Module.java
index 9fc2709..e57bd51 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/findowners/Module.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/findowners/Module.java
@@ -47,6 +47,7 @@
 
   @Override
   protected void configure() {
+    install(OwnersValidator.module());
     install(
         new RestApiModule() {
           @Override
diff --git a/src/main/java/com/googlesource/gerrit/plugins/findowners/OwnersDb.java b/src/main/java/com/googlesource/gerrit/plugins/findowners/OwnersDb.java
index b2ce1f9..2c595b2 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/findowners/OwnersDb.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/findowners/OwnersDb.java
@@ -16,8 +16,12 @@
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.common.collect.Multimap;
+import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Project;
-import java.io.IOException;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.Emails;
+import com.google.gerrit.server.query.change.ChangeData;
 import java.nio.file.FileSystem;
 import java.nio.file.FileSystems;
 import java.nio.file.PathMatcher;
@@ -26,8 +30,10 @@
 import java.util.Collection;
 import java.util.HashMap;
 import java.util.HashSet;
+import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevTree;
@@ -40,6 +46,8 @@
 class OwnersDb {
   private static final Logger log = LoggerFactory.getLogger(OwnersDb.class);
 
+  private AccountCache accountCache;
+  private Emails emails;
   private int numOwners = -1; // # of owners of all given files.
 
   String key = ""; // key to find this OwnersDb in a cache.
@@ -49,42 +57,54 @@
   Map<String, Set<String>> path2Owners = new HashMap<>(); // dir or file glob to owner emails
   Set<String> readDirs = new HashSet<>(); // directories in which we have checked OWNERS
   Set<String> stopLooking = new HashSet<>(); // directories where OWNERS has "set noparent"
+  Map<String, String> preferredEmails = new HashMap<>(); // owner email to preferred email
+  List<String> errors = new ArrayList<>(); // error messages
 
   OwnersDb() {}
 
   OwnersDb(
+      AccountCache accountCache,
+      Emails emails,
       String key,
       Repository repository,
+      ChangeData changeData,
       Project.NameKey project,
       String branch,
       Collection<String> files) {
+    this.accountCache = accountCache;
+    this.emails = emails;
     this.key = key;
-    String ownersFileName = Config.getOwnersFileName(project);
-    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); // e.g. dir = ./d1/d2
-      while (!readDirs.contains(dir)) {
-        readDirs.add(dir);
-        String filePath = (dir + "/" + ownersFileName).substring(2); // remove "./"
-        String content = getRepositoryFile(repository, branch, filePath);
-        if (content != null && !content.equals("")) {
-          addFile(dir + "/", dir + "/" + ownersFileName, content.split("\\R+"));
+    preferredEmails.put("*", "*");
+    String ownersFileName = Config.getOwnersFileName(project, changeData);
+    // Some hacked CL could have a target branch that is not created yet.
+    ObjectId id = getBranchId(repository, branch, changeData);
+    revision = "";
+    if (id != null) {
+      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); // e.g. dir = ./d1/d2
+        while (!readDirs.contains(dir)) {
+          readDirs.add(dir);
+          String filePath = (dir + "/" + ownersFileName).substring(2); // remove "./"
+          String content = getRepositoryFile(repository, id, filePath);
+          if (content != null && !content.equals("")) {
+            addFile(dir + "/", dir + "/" + ownersFileName, content.split("\\R+"));
+          }
+          if (stopLooking.contains(dir + "/") || !dir.contains("/")) {
+            break; // stop looking through parent directory
+          }
+          dir = Util.getDirName(dir); // go up one level
         }
-        if (stopLooking.contains(dir + "/") || !dir.contains("/")) {
-          break; // stop looking through parent directory
-        }
-        dir = Util.getDirName(dir); // go up one level
+      }
+      try {
+        revision = repository.getRef(branch).getObjectId().getName();
+      } catch (Exception e) {
+        log.error("Fail to get branch revision for " + Config.getChangeId(changeData), e);
       }
     }
     countNumOwners(files);
-    try {
-      revision = repository.getRef(branch).getObjectId().getName();
-    } catch (IOException e) {
-      log.error("Fail to get branch revision", e);
-      revision = "";
-    }
   }
 
   int getNumOwners() {
@@ -110,14 +130,50 @@
     }
   }
 
+  void addPreferredEmails(Set<String> ownerEmails) {
+    List<String> owners = new ArrayList<>(ownerEmails);
+    owners.removeIf(o -> preferredEmails.get(o) != null);
+    if (!owners.isEmpty()) {
+      String[] ownerEmailsAsArray = new String[owners.size()];
+      owners.toArray(ownerEmailsAsArray);
+      Multimap<String, Account.Id> email2ids = null;
+      try {
+        email2ids = emails.getAccountsFor(ownerEmailsAsArray);
+      } catch (Exception e) {
+        log.error("accounts.byEmails failed with exception: ", e);
+      }
+      for (String owner : ownerEmailsAsArray) {
+        String email = owner;
+        try {
+          if (email2ids == null) {
+            errors.add(owner);
+          } else {
+            Collection<Account.Id> ids = email2ids.get(owner);
+            if (ids == null || ids.size() != 1) {
+              errors.add(owner);
+            } else {
+              email = accountCache.get(ids.iterator().next()).getAccount().getPreferredEmail();
+            }
+          }
+        } catch (Exception e) {
+          log.error("Fail to find preferred email of " + owner, e);
+          errors.add(owner);
+        }
+        preferredEmails.put(owner, email);
+      }
+    }
+  }
+
   void addFile(String dirPath, String filePath, String[] lines) {
     Parser.Result result = Parser.parseFile(dirPath, filePath, lines);
     if (result.stopLooking) {
       stopLooking.add(dirPath);
     }
+    addPreferredEmails(result.owner2paths.keySet());
     for (String owner : result.owner2paths.keySet()) {
+      String email = preferredEmails.get(owner);
       for (String path : result.owner2paths.get(owner)) {
-        addOwnerPathPair(owner, path);
+        addOwnerPathPair(email, path);
       }
     }
     if (Config.getReportSyntaxError()) {
@@ -232,16 +288,30 @@
     return file2Owners;
   }
 
+  /** Returns ObjectId of the given branch, or null. */
+  private static ObjectId getBranchId(Repository repo, String branch, ChangeData changeData) {
+    try {
+      ObjectId id = repo.resolve(branch);
+      if (id == null && changeData != null && !Checker.isExemptFromOwnerApproval(changeData)) {
+        log.error("cannot find branch " + branch + " for " + Config.getChangeId(changeData));
+      }
+      return id;
+    } catch (Exception e) {
+      log.error("cannot find branch " + branch + " for " + Config.getChangeId(changeData), e);
+    }
+    return null;
+  }
+
   /** Returns file content or empty string; uses Repository. */
-  static String getRepositoryFile(Repository repo, String branch, String file) {
+  private static String getRepositoryFile(Repository repo, ObjectId id, String file) {
     try (RevWalk revWalk = new RevWalk(repo)) {
-      RevTree tree = revWalk.parseCommit(repo.resolve(branch)).getTree();
+      RevTree tree = revWalk.parseCommit(id).getTree();
       ObjectReader reader = revWalk.getObjectReader();
       TreeWalk treeWalk = TreeWalk.forPath(reader, file, tree);
       if (treeWalk != null) {
         return new String(reader.open(treeWalk.getObjectId(0)).getBytes(), UTF_8);
       }
-    } catch (IOException e) {
+    } catch (Exception e) {
       log.error("get file " + file, e);
     }
     return "";
diff --git a/src/main/java/com/googlesource/gerrit/plugins/findowners/OwnersValidator.java b/src/main/java/com/googlesource/gerrit/plugins/findowners/OwnersValidator.java
new file mode 100644
index 0000000..8d81574
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/findowners/OwnersValidator.java
@@ -0,0 +1,354 @@
+// 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.googlesource.gerrit.plugins.findowners.Config.OWNERS;
+import static com.googlesource.gerrit.plugins.findowners.Config.OWNERS_FILE_NAME;
+import static com.googlesource.gerrit.plugins.findowners.Config.REJECT_ERROR_IN_OWNERS;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.Multimap;
+import com.google.gerrit.extensions.annotations.Exports;
+import com.google.gerrit.extensions.annotations.PluginName;
+import com.google.gerrit.extensions.api.projects.ProjectConfigEntryType;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.account.Emails;
+import com.google.gerrit.server.config.PluginConfig;
+import com.google.gerrit.server.config.PluginConfigFactory;
+import com.google.gerrit.server.config.ProjectConfigEntry;
+import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.validators.CommitValidationException;
+import com.google.gerrit.server.git.validators.CommitValidationListener;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ExecutionException;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.eclipse.jgit.diff.RawText;
+import org.eclipse.jgit.lib.FileMode;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectLoader;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
+import org.eclipse.jgit.treewalk.filter.TreeFilter;
+
+/** Check syntax of changed OWNERS files. */
+public class OwnersValidator implements CommitValidationListener {
+  private interface TreeWalkVisitor {
+    void onVisit(TreeWalk tw);
+  }
+
+  public static AbstractModule module() {
+    return new AbstractModule() {
+      @Override
+      protected void configure() {
+        DynamicSet.bind(binder(), CommitValidationListener.class).to(OwnersValidator.class);
+        bind(ProjectConfigEntry.class)
+            .annotatedWith(Exports.named(REJECT_ERROR_IN_OWNERS))
+            .toInstance(
+                new ProjectConfigEntry(
+                    "Reject OWNERS Files With Errors",
+                    null,
+                    ProjectConfigEntryType.BOOLEAN,
+                    null,
+                    false,
+                    "Pushes of commits with errors in OWNERS files will be rejected."));
+      }
+    };
+  }
+
+  private final String pluginName;
+  private final PluginConfigFactory cfgFactory;
+  private final GitRepositoryManager repoManager;
+  private final Emails emails;
+
+  @Inject
+  OwnersValidator(
+      @PluginName String pluginName,
+      PluginConfigFactory cfgFactory,
+      GitRepositoryManager repoManager,
+      Emails emails) {
+    this.pluginName = pluginName;
+    this.cfgFactory = cfgFactory;
+    this.repoManager = repoManager;
+    this.emails = emails;
+  }
+
+  public static String getOwnersFileName(PluginConfig cfg) {
+    return getOwnersFileName(cfg, OWNERS);
+  }
+
+  public static String getOwnersFileName(PluginConfig cfg, String defaultName) {
+    return cfg.getString(OWNERS_FILE_NAME, defaultName);
+  }
+
+  public String getOwnersFileName(Project.NameKey project) {
+    String name = getOwnersFileName(cfgFactory.getFromGerritConfig(pluginName, true));
+    try {
+      return getOwnersFileName(
+          cfgFactory.getFromProjectConfigWithInheritance(project, pluginName), name);
+    } catch (NoSuchProjectException e) {
+      return name;
+    }
+  }
+
+  @VisibleForTesting
+  static boolean isActive(PluginConfig cfg) {
+    return cfg.getBoolean(REJECT_ERROR_IN_OWNERS, false);
+  }
+
+  @Override
+  public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+      throws CommitValidationException {
+    List<CommitValidationMessage> messages = new LinkedList<>();
+    try {
+      Project.NameKey project = receiveEvent.project.getNameKey();
+      PluginConfig cfg = cfgFactory.getFromProjectConfigWithInheritance(project, pluginName);
+      if (isActive(cfg)) {
+        try (Repository repo = repoManager.openRepository(project)) {
+          String name = getOwnersFileName(project);
+          messages = performValidation(receiveEvent.commit, receiveEvent.revWalk, name, false);
+        }
+      }
+    } catch (NoSuchProjectException | IOException | ExecutionException e) {
+      throw new CommitValidationException("failed to check owners files", e);
+    }
+    if (hasError(messages)) {
+      throw new CommitValidationException("found invalid owners file", messages);
+    }
+    return messages;
+  }
+
+  @VisibleForTesting
+  List<CommitValidationMessage> performValidation(
+      RevCommit c, RevWalk revWalk, String ownersFileName, boolean verbose)
+      throws IOException, ExecutionException {
+    // Collect all messages from all files.
+    List<CommitValidationMessage> messages = new LinkedList<>();
+    // Collect all email addresses from all files and check each address only once.
+    Map<String, Set<String>> email2lines = new HashMap<>();
+    Map<String, ObjectId> content = getChangedOwners(c, revWalk, ownersFileName);
+    for (String path : content.keySet()) {
+      ObjectLoader ol = revWalk.getObjectReader().open(content.get(path));
+      try (InputStream in = ol.openStream()) {
+        if (RawText.isBinary(in)) {
+          add(messages, path + " is a binary file", true); // OWNERS files cannot be binary
+          continue;
+        }
+      }
+      checkFile(messages, email2lines, path, ol, verbose);
+    }
+    checkEmails(messages, emails, email2lines, verbose);
+    return messages;
+  }
+
+  private static void checkEmails(
+      List<CommitValidationMessage> messages,
+      Emails emails,
+      Map<String, Set<String>> email2lines,
+      boolean verbose) {
+    List<String> owners = new ArrayList<>(email2lines.keySet());
+    if (verbose) {
+      for (String owner : owners) {
+        add(messages, "owner: " + owner, false);
+      }
+    }
+    if (emails == null || owners.isEmpty()) {
+      return;
+    }
+    String[] ownerEmailsAsArray = new String[owners.size()];
+    owners.toArray(ownerEmailsAsArray);
+    try {
+      Multimap<String, Account.Id> email2ids = emails.getAccountsFor(ownerEmailsAsArray);
+      for (String owner : ownerEmailsAsArray) {
+        boolean wrongEmail = (email2ids == null);
+        if (!wrongEmail) {
+          try {
+            Collection<Account.Id> ids = email2ids.get(owner);
+            wrongEmail = (ids == null || ids.size() != 1);
+          } catch (Exception e) {
+            wrongEmail = true;
+          }
+        }
+        if (wrongEmail) {
+          String locations = String.join(" ", email2lines.get(owner));
+          add(messages, "unknown: " + owner + " at " + locations, true);
+        }
+      }
+    } catch (Exception e) {
+      add(messages, "checkEmails failed.", true);
+    }
+  }
+
+  private static void checkFile(
+      List<CommitValidationMessage> messages,
+      Map<String, Set<String>> email2lines,
+      String path,
+      ObjectLoader ol,
+      boolean verbose)
+      throws IOException {
+    if (verbose) {
+      add(messages, "validate: " + path, false);
+    }
+    try (BufferedReader br =
+        new BufferedReader(new InputStreamReader(ol.openStream(), StandardCharsets.UTF_8))) {
+      int line = 0;
+      for (String l = br.readLine(); l != null; l = br.readLine()) {
+        line++;
+        checkLine(messages, email2lines, path, line, l);
+      }
+    }
+  }
+
+  // Line patterns accepted by Parser.java in the find-owners plugin.
+  static final Pattern patComment = Pattern.compile("^ *(#.*)?$");
+  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 patPerFileNoParent =
+      Pattern.compile("^ *per-file +([^= ]+) *= *set +noparent *(#.*)?$");
+  static final Pattern patPerFileEmail =
+      Pattern.compile("^ *per-file +([^= ]+) *= *([^ <>@]+@[^ <>@#]+|\\*) *(#.*)?$");
+
+  private static void collectEmail(
+      Map<String, Set<String>> map, String email, String file, int lineNumber) {
+    if (!email.equals("*")) {
+      map.computeIfAbsent(email, (String k) -> new HashSet<>());
+      map.get(email).add(file + ":" + lineNumber);
+    }
+  }
+
+  private static boolean hasError(List<CommitValidationMessage> messages) {
+    for (CommitValidationMessage m : messages) {
+      if (m.isError()) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  private static void add(List<CommitValidationMessage> messages, String msg, boolean error) {
+    messages.add(new CommitValidationMessage(msg, error));
+  }
+
+  private static void checkLine(
+      List<CommitValidationMessage> messages,
+      Map<String, Set<String>> email2lines,
+      String path,
+      int lineNumber,
+      String line) {
+    Matcher m;
+    if (patComment.matcher(line).find()
+        || patNoParent.matcher(line).find()
+        || patPerFileNoParent.matcher(line).find()) {
+      return;
+    } else if ((m = patEmail.matcher(line)).find()) {
+      collectEmail(email2lines, m.group(1), path, lineNumber);
+    } else if ((m = patPerFileEmail.matcher(line)).find()) {
+      collectEmail(email2lines, m.group(2).trim(), path, lineNumber);
+    } else {
+      String prefix = patFile.matcher(line).find() ? "ignored" : "syntax";
+      add(messages, prefix + ": " + path + ":" + lineNumber + ": " + line, true);
+    }
+  }
+
+  /**
+   * Find all changed OWNERS files which differ between the commit and its parents. Return a map
+   * from "Path to the changed file" to "ObjectId of the file".
+   */
+  private static Map<String, ObjectId> getChangedOwners(
+      RevCommit c, RevWalk revWalk, String ownersFileName) throws IOException {
+    final Map<String, ObjectId> content = new HashMap<>();
+    visitChangedEntries(
+        c,
+        revWalk,
+        new TreeWalkVisitor() {
+          @Override
+          public void onVisit(TreeWalk tw) {
+            if (isFile(tw) && ownersFileName.equals(tw.getNameString())) {
+              content.put(tw.getPathString(), tw.getObjectId(0));
+            }
+          }
+        });
+    return content;
+  }
+
+  private static boolean isFile(TreeWalk tw) {
+    return FileMode.EXECUTABLE_FILE.equals(tw.getRawMode(0))
+        || FileMode.REGULAR_FILE.equals(tw.getRawMode(0));
+  }
+
+  /**
+   * Find all TreeWalk entries which differ between the commit and its parents. If a TreeWalk entry
+   * is found this method calls the onVisit() method of the class TreeWalkVisitor.
+   */
+  private static void visitChangedEntries(RevCommit c, RevWalk revWalk, TreeWalkVisitor visitor)
+      throws IOException {
+    try (TreeWalk tw = new TreeWalk(revWalk.getObjectReader())) {
+      tw.setRecursive(true);
+      tw.setFilter(TreeFilter.ANY_DIFF);
+      tw.addTree(c.getTree());
+      if (c.getParentCount() > 0) {
+        for (RevCommit p : c.getParents()) {
+          if (p.getTree() == null) {
+            revWalk.parseHeaders(p);
+          }
+          tw.addTree(p.getTree());
+        }
+        while (tw.next()) {
+          if (isDifferentToAllParents(c, tw)) {
+            visitor.onVisit(tw);
+          }
+        }
+      } else {
+        while (tw.next()) {
+          visitor.onVisit(tw);
+        }
+      }
+    }
+  }
+
+  private static boolean isDifferentToAllParents(RevCommit c, TreeWalk tw) {
+    if (c.getParentCount() > 1) {
+      for (int p = 1; p <= c.getParentCount(); p++) {
+        if (tw.getObjectId(0).equals(tw.getObjectId(p))) {
+          return false;
+        }
+      }
+    }
+    return true;
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/findowners/RestResult.java b/src/main/java/com/googlesource/gerrit/plugins/findowners/RestResult.java
index f91ecbe..296f408 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/findowners/RestResult.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/findowners/RestResult.java
@@ -54,6 +54,7 @@
     String user;
     String project;
     String branch;
+    List<String> errors;
     SortedMap<String, List<String>> path2owners;
     SortedMap<String, List<String>> owner2paths;
   };
diff --git a/src/main/prolog/find_owners.pl b/src/main/prolog/find_owners.pl
index 6afd829..306339b 100644
--- a/src/main/prolog/find_owners.pl
+++ b/src/main/prolog/find_owners.pl
@@ -50,7 +50,12 @@
   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(_))) :-
+% If owner approval is required and missing,
+% use the owner_approval_missing(X) label and may(_) state to
+% enable the Submit button. Front-end JavaScript should check
+% the label and then block the submit or suggest users to
+% add "Exempt-From-Owner-Approval:" to the commit message.
+create_owner_approval_label(_, label(X, may(_))) :-
   owner_approval_missing(X).
 
 has_owner_approval_label([label(X, _)|_]) :- owner_approval_label(X).
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md
index d754912..fa046cc 100644
--- a/src/main/resources/Documentation/config.md
+++ b/src/main/resources/Documentation/config.md
@@ -36,10 +36,13 @@
   `label('Owner-Approved', ok(user(1)))` is added.
 * If a change needs owner approval, but some changed file has no *owner*
   +1 vote or has negative *owner* vote,
-  `label('Owner-Review-Vote', need(_))` is added.
-  This will make a change *not-submittable*.
-  The change author should add missing *owners* to the
-  reviewers list and/or ask for those owner's +1 Code-Review votes.
+  `label('Owner-Review-Vote', may(_))` is added.
+  This will show the label but not disable the Submit button.
+  When a user clicks on the Submit button,
+  a window will pop up and ask the user to
+  (1) add missing *owners* to the reviewers list and/or
+      ask for owner's +1 Code-Review votes, or
+  (2) add `Exempt-From-Owner-Approval:` to the commit message.
   The **`Find Owners`** button is useful in this situation to find
   the missing *owners* or +1 votes of any changed files.
 
@@ -67,6 +70,16 @@
 If a project has already used OWNERS files for other purpose,
 the "ownersFileName" parameter can be used to change the default.
 
+## Validate OWNERS files before upload
+
+To check syntax of OWNERS files before they are uploaded,
+set the following variable in project.config files.
+
+```bash
+[plugin "find-owners"]
+    rejectErrorInOwners = true
+```
+
 ## Example 0, call `submit_filter/2`
 
 The simplest configuration adds to `rules.pl` of the root
diff --git a/src/main/resources/Documentation/rest-api.md b/src/main/resources/Documentation/rest-api.md
index 7676059..10b1565 100644
--- a/src/main/resources/Documentation/rest-api.md
+++ b/src/main/resources/Documentation/rest-api.md
@@ -54,6 +54,8 @@
 
     * **branch**: the change's destination branch name.
 
+    * **errors**: error messages from OWNERS files.
+
     * **path2owners**:
       a map from directory path or file glob to an array of owner emails.
       Note that `*` is a special owner email address.
diff --git a/src/main/resources/static/find-owners.js b/src/main/resources/static/find-owners.js
index 8122474..0abdabe 100644
--- a/src/main/resources/static/find-owners.js
+++ b/src/main/resources/static/find-owners.js
@@ -13,23 +13,63 @@
 // 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>';
+  if (window.Polymer) {
+    // Install deprecated APIs to mimic GWT UI API.
+    self.deprecated.install();
+  }
+
+  // If context.popup API exists and popup content is small,
+  // use the API and set useContextPopup,
+  // otherwise, use pageDiv and set its visibility.
+  var useContextPopup = false;
+  var pageDiv = document.createElement('div');
+  document.body.appendChild(pageDiv);
+  function hideFindOwnersPage() {
+    pageDiv.style.visibility = 'hidden';
+  }
+  function popupFindOwnersPage(context, change, revision, onSubmit) {
+    const PADDING = 5;
+    const LARGE_PAGE_STYLE = Gerrit.css(
+        'visibility: hidden;' +
+        'background: rgba(200, 200, 200, 0.95);' +
+        'border: 3px solid;' +
+        'border-color: #c8c8c8;' +
+        'border-radius: 3px;' +
+        'position: fixed;' +
+        'z-index: 100;' +
+        'overflow: auto;' +
+        'padding: ' + PADDING + 'px;'
+        );
+    const BUTTON_STYLE = Gerrit.css(
+        'background-color: #4d90fe;' +
+        'border: 2px solid;' +
+        'border-color: #4d90fe;' +
+        'margin: 2px 10px 2px 10px;' +
+        'text-align: center;' +
+        'font-size: 8pt;' +
+        'font-weight: bold;' +
+        'color: #fff;' +
+        '-webkit-border-radius: 2px;' +
+        'cursor: pointer;'
+        );
     const HTML_BULLET = '<small>&#x2605;</small>'; // a Black Star
+    const HTML_HAS_APPROVAL_HEADER =
+        '<hr><b>Files with +1 or +2 Code-Review vote from owners:</b><br>';
     const HTML_IS_EXEMPTED =
-        '<b>This commit is exempted from owner approval.</b><br>';
+        '<b>This change is Exempt-From-Owner-Approval.</b>';
     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>';
+        '<b>No owner was found for changed files.</b>';
+    const HTML_ONSUBMIT_HEADER =
+        '<b>WARNING: Need owner approval vote before submit.</b><hr>';
     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. ' +
+        '</b><br><small>Each file needs at least one owner code review vote. ' +
         'Owners listed after a file are ordered by their importance. ' +
         '(Or declare "<b><span style="font-size:80%;">' +
         'Exempt-From-Owner-Approval:</span></b> ' +
@@ -39,15 +79,18 @@
     const CHECKBOX_ID = 'FindOwners:CheckBox';
     const HEADER_DIV_ID = 'FindOwners:Header';
     const OWNERS_DIV_ID = 'FindOwners:Owners';
+    const HAS_APPROVAL_DIV_ID = 'FindOwners:HasApproval';
     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;
+    const branch = change.branch;
+    const changeId = change._number;
+    const changeOwner = change.owner;
+    const message = revision.commit.message;
+    const project = change.project;
 
+    var minVoteLevel = 1; // could be changed by server returned results.
     var reviewerId = {}; // map from a reviewer's email to account id.
     var reviewerVote = {}; // map from a reviewer's email to Code-Review vote.
 
@@ -59,8 +102,39 @@
     function getElement(id) {
       return document.getElementById(id);
     }
+    function httpGet(url, callback) {
+      var xhr = new XMLHttpRequest();
+      xhr.open('GET', url);
+      xhr.onreadystatechange = function() {
+        if (xhr.readyState == XMLHttpRequest.DONE) {
+          // skip special characters ")]}'\n"
+          callback(!!xhr.responseText ?
+                   JSON.parse(xhr.responseText.substring(5)) : {});
+        }
+      };
+      xhr.send();
+    }
+    function httpError(msg, callback) {
+      console.log('UNIMPLEMENTED: ' + msg);
+      callback();
+    }
+    function gerritGet(url, callback) {
+      (!!Gerrit.get) ? Gerrit.get(url, callback) :
+          ((!!self.get) ? self.get('/../..' + url, callback) :
+              httpGet(url, callback));
+    }
+    function gerritPost(url, data, callback) {
+      (!!Gerrit.post) ? Gerrit.post(url, data, callback) :
+          ((!!self.post) ? self.post('/../..' + url, data, callback) :
+              httpError('POST ' + url, callback));
+    }
+    function gerritDelete(url, callback) {
+      (!!Gerrit.delete) ? Gerrit.delete(url, callback) :
+          ((!!self.delete) ? self.delete('/../..' + url, callback) :
+              httpError('DELETE ' + url, callback));
+    }
     function getReviewers(change, callBack) {
-      Gerrit.get('changes/' + change + '/reviewers', callBack);
+      gerritGet('/changes/' + change + '/reviewers', callBack);
     }
     function setupReviewersMap(reviewerList) {
       reviewerId = {};
@@ -76,6 +150,14 @@
           }
         }
       });
+      // Give CL author a default minVoteLevel vote.
+      if (changeOwner != null &&
+          'email' in changeOwner && '_account_id' in changeOwner &&
+          (!(changeOwner.email in reviewerId) ||
+           reviewerVote[changeOwner.email] == 0)) {
+        reviewerId[changeOwner.email] = changeOwner._account_id;
+        reviewerVote[changeOwner.email] = minVoteLevel;
+      }
     }
     function checkAddRemoveLists() {
       // Gerrit.post and delete are asynchronous.
@@ -88,9 +170,9 @@
           // 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);
+          gerritPost('/changes/' + changeId + '/reviewers',
+                     {'reviewer': email},
+                     checkAddRemoveLists);
           return;
         }
       }
@@ -99,16 +181,16 @@
         if (email in reviewerId) {
           removeList = removeList.slice(i + 1, removeList.length);
           needRefresh = true;
-          Gerrit.delete('changes/' + changeId +
-                        '/reviewers/' + reviewerId[email],
-                        checkAddRemoveLists);
+          gerritDelete('/changes/' + changeId +
+                       '/reviewers/' + reviewerId[email],
+                       checkAddRemoveLists);
           return;
         }
       }
-      c.hide();
+      hideFindOwnersPage();
       if (needRefresh) {
         needRefresh = false;
-        Gerrit.refresh();
+        (!!Gerrit.refresh) ? Gerrit.refresh() : location.reload();
       }
       callServer(showFindOwnersResults);
     }
@@ -121,7 +203,7 @@
         return (owner in reviewers || owner == '*');
       });
     }
-    function hasOwnerApproval(votes, minVoteLevel, owners) {
+    function hasOwnerApproval(votes, owners) {
       var foundApproval = false;
       for (var j = 0; j < owners.length; j++) {
         if (owners[j] in votes) {
@@ -129,7 +211,6 @@
           if (v < 0) {
             return false; // cannot have any negative vote
           }
-          // TODO: do not count if owners[j] is the patch committer.
           foundApproval |= v >= minVoteLevel;
         }
       }
@@ -147,26 +228,40 @@
       e.innerHTML = s;
       return e;
     }
+    function br() {
+      return document.createElement('br');
+    }
+    function hr() {
+      return document.createElement('hr');
+    }
+    function newButton(name, action) {
+      var b = document.createElement('button');
+      b.appendChild(document.createTextNode(name));
+      b.className = BUTTON_STYLE;
+      b.onclick = action;
+      return b;
+    }
     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());
+      args.push(hr(), strElement('<b>' + key + '</b>:'), br());
       value.split('\n').forEach(function(line) {
-        args.push(c.msg(line), c.br());
+        args.push(strElement(line), br());
       });
     }
     function showDebugMessages(result, args) {
       function addKeyValue(key, value) {
         args.push(strElement('<b>' + key + '</b>: ' + value + '<br>'));
       }
-      args.push(c.hr());
+      args.push(hr());
       addKeyValue('changeId', changeId);
       addKeyValue('project', project);
       addKeyValue('branch', branch);
+      addKeyValue('changeOwner.email', changeOwner.email);
       addKeyValue('Gerrit.url', Gerrit.url());
       addKeyValue('self.url', self.url());
-      showJsonLines(args, 'changeOwner', c.change.owner);
+      showJsonLines(args, 'changeOwner', change.owner);
       showBoldKeyValueLines(args, 'commit.message', message);
       showJsonLines(args, 'Client reviewers Ids', reviewerId);
       showJsonLines(args, 'Client reviewers Votes', reviewerVote);
@@ -184,14 +279,18 @@
       var header = emptyDiv(HEADER_DIV_ID);
       var needReviewerDiv = emptyDiv(NEED_REVIEWER_DIV_ID);
       var needApprovalDiv = emptyDiv(NEED_APPROVAL_DIV_ID);
+      var hasApprovalDiv = emptyDiv(HAS_APPROVAL_DIV_ID);
       addApplyButton();
+      args.push(newButton('Cancel', hideFindOwnersPage));
       var ownersDiv = emptyDiv(OWNERS_DIV_ID);
       var numCheckBoxes = 0;
       var owner2boxes = {}; // owner name ==> array of checkbox id
       var owner2email = {}; // owner name ==> email address
+      minVoteLevel =
+          ('minOwnerVoteLevel' in result ? result.minOwnerVoteLevel : 1);
 
       function addApplyButton() {
-        var apply = c.button('Apply', {onclick: doApplyButton});
+        var apply = newButton('Apply', doApplyButton);
         apply.id = APPLY_BUTTON_ID;
         apply.style.display = 'none';
         args.push(apply);
@@ -237,7 +336,8 @@
             owner2boxes[name] = [];
           }
           owner2boxes[name].push(id);
-          var box = c.checkbox();
+          var box = document.createElement('input');
+          box.type = 'checkbox';
           box.checked = (ownerEmail in reviewerId);
           box.id = id;
           box.value = name;
@@ -262,7 +362,7 @@
           }
           div.appendChild(strElement(item));
           sortedOwners.reduce(add2list, []).forEach(addOwner);
-          div.appendChild(c.br());
+          div.appendChild(br());
         });
       }
       function addOwnersDiv(div, title) {
@@ -285,6 +385,7 @@
       function updateDivContent() {
         var groupNeedReviewer = [];
         var groupNeedApproval = [];
+        var groupHasApproval = [];
         numCheckBoxes = 0;
         owner2boxes = {};
         Object.keys(groups).sort().forEach(function(key) {
@@ -293,24 +394,22 @@
             groupNeedReviewer.push(key);
           } else if (g.needApproval) {
             groupNeedApproval.push(key);
+          } else {
+            groupHasApproval.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);
-        }
+        showDiv(header,
+                (onSubmit ? HTML_ONSUBMIT_HEADER : '') + HTML_SELECT_REVIEWERS);
+        addGroupsToDiv(needReviewerDiv, groupNeedReviewer,
+                       HTML_NEED_REVIEWER_HEADER);
+        addGroupsToDiv(needApprovalDiv, groupNeedApproval,
+                       HTML_NEED_APPROVAL_HEADER);
+        addGroupsToDiv(hasApprovalDiv, groupHasApproval,
+                       HTML_HAS_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 splitOwners = result.file2owners[name];
           var owners = splitOwners.join(' ');
@@ -321,7 +420,7 @@
             groupSize[name] = 1;
             var needReviewer = !hasOwnerReviewer(reviewerId, splitOwners);
             var needApproval = !needReviewer &&
-                !hasOwnerApproval(reviewerVote, minVoteLevel, splitOwners);
+                !hasOwnerApproval(reviewerVote, splitOwners);
             groups[name] = {
               'needReviewer': needReviewer,
               'needApproval': needApproval,
@@ -333,29 +432,98 @@
       updateDivContent();
     }
     function showFindOwnersResults(result) {
+      function prepareElements() {
+        var elems = [];
+        var text = isExemptedFromOwnerApproval() ? HTML_IS_EXEMPTED :
+            (Object.keys(result.file2owners).length <= 0 ?
+                HTML_NO_OWNER : null);
+        useContextPopup = !!context && !!text && !!context.popup;
+        if (!!text) {
+          if (useContextPopup) {
+            elems.push(hr(), strElement(text), hr());
+            var onClick = function() { context.hide(); };
+            elems.push(context.button('OK', {onclick: onClick}), hr());
+          } else {
+            elems.push(strElement(text), newButton('OK', hideFindOwnersPage));
+          }
+        } else {
+          showFilesAndOwners(result, elems);
+          if (result.addDebugMsg) {
+            showDebugMessages(result, elems);
+          }
+        }
+        return elems;
+      }
       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));
+        var elems = prepareElements();
+        if (useContextPopup) {
+          context.popup(context.div.apply(this, elems));
         } else {
-          showFilesAndOwners(result, args);
+          while (pageDiv.firstChild) {
+            pageDiv.removeChild(pageDiv.firstChild);
+          }
+          elems.forEach(function(e) { pageDiv.appendChild(e); });
+          pageDiv.className = LARGE_PAGE_STYLE;
+          // Calculate required height, limited to 85% of window height,
+          // and required width, limited to 75% of window width.
+          pageDiv.style.top = '5%';
+          pageDiv.style.height = 'auto';
+          pageDiv.style.left = '10%';
+          pageDiv.style.width = 'auto';
+          var rect = pageDiv.getBoundingClientRect();
+          var h = Math.round(Math.min(rect.height - 2 * PADDING,
+                                      window.innerHeight * 0.85));
+          pageDiv.style.height = h + 'px';
+          pageDiv.style.top = Math.round((window.innerHeight - h) / 2) + 'px';
+          var w = Math.round(Math.min(rect.width - 2 * PADDING,
+                                      window.innerWidth * 0.75));
+          pageDiv.style.width = w + 'px';
+          pageDiv.style.left = Math.round((window.innerWidth - w) / 2) + 'px';
+          pageDiv.style.visibility = 'visible';
         }
-        if (result.addDebugMsg) {
-          showDebugMessages(result, args);
-        }
-        c.popup(c.div.apply(this, args));
       }
       getReviewers(changeId, popupWindow);
     }
     function callServer(callBack) {
       // Use the plugin REST API; pass only changeId;
       // let server get current patch set, project and branch info.
-      Gerrit.get('changes/' + changeId + '/owners', showFindOwnersResults);
+      gerritGet('/changes/' + changeId + '/owners', showFindOwnersResults);
     }
+    event.stopPropagation();
     callServer(showFindOwnersResults);
   }
-  self.onAction('revision', 'find-owners', onFindOwners);
+  function onFindOwners(context) {
+    popupFindOwnersPage(context, context.change, context.revision, false);
+  }
+  function onSubmit(change, revision) {
+    const OWNER_REVIEW_LABEL = 'Owner-Review-Vote';
+    if (change.labels.hasOwnProperty(OWNER_REVIEW_LABEL)) {
+      // Pop up Find Owners page; do not submit.
+      popupFindOwnersPage(null, change, revision, true);
+      return false;
+    }
+    return true; // Okay to submit.
+  }
+  function onClick(e) {
+    if (pageDiv.style.visibility != 'hidden' && !useContextPopup) {
+      var x = event.clientX;
+      var y = event.clientY;
+      var rect = pageDiv.getBoundingClientRect();
+      if (x < rect.left || x >= rect.left + rect.width ||
+          y < rect.top || y >= rect.top + rect.height) {
+        hideFindOwnersPage();
+      }
+    }
+  }
+  // When the "Find Owners" button is clicked, call onFindOwners.
+  if (!!self.onAction) { // PolyGerrit does not have self.onAction
+    self.onAction('revision', 'find-owners', onFindOwners);
+  } else {
+    console.log('WARNING, no handler for the Find Owners button');
+  }
+  // When the "Submit" button is clicked, call onSubmit.
+  self.on('submitchange', onSubmit);
+  // Clicks outside the pop up window should close the window.
+  document.body.addEventListener('click', onClick);
 });
diff --git a/src/test/java/com/googlesource/gerrit/plugins/findowners/FindOwnersIT.java b/src/test/java/com/googlesource/gerrit/plugins/findowners/FindOwnersIT.java
index 2e841e8..160d5fa 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/findowners/FindOwnersIT.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/findowners/FindOwnersIT.java
@@ -18,10 +18,12 @@
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_CONFIG;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.common.collect.Multimap;
 import com.google.gerrit.acceptance.LightweightPluginDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
 import com.google.gerrit.acceptance.TestPlugin;
+import com.google.gerrit.extensions.api.accounts.EmailInput;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.common.ChangeInfo;
@@ -30,10 +32,13 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.account.Emails;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.inject.Inject;
+import java.util.Collection;
 import org.eclipse.jgit.lib.ObjectLoader;
+import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevObject;
 import org.eclipse.jgit.revwalk.RevTree;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -44,6 +49,7 @@
 @TestPlugin(name = "find-owners", sysModule = "com.googlesource.gerrit.plugins.findowners.Module")
 public class FindOwnersIT extends LightweightPluginDaemonTest {
 
+  @Inject private Emails emails;
   @Inject private PluginConfigFactory configFactory;
 
   @Test
@@ -170,13 +176,46 @@
 
   @Test
   public void accountTest() throws Exception {
-    String[] myTestEmails = {"abc@g.com", "abc+xyz@g.com", "xyz@g.com"};
-    for (String email : myTestEmails) {
-      Account.Id id = accounts.create("User" + email, email, "FullName" + email).getId();
-      // Action.getReviewers uses accountCache to get email address.
-      assertThat(accountCache.get(id).getAccount().getPreferredEmail()).isEqualTo(email);
-      // Checker.getVotes uses AccountAccess to get email address.
-      assertThat(db.accounts().get(id).getPreferredEmail()).isEqualTo(email);
+    String[] users = {"user1", "user2", "user3"};
+    String[] emails1 = {"abc@g.com", "abc+xyz@g.com", "xyz-team+review@g.com"};
+    String[] emails2 = {"abc@goog.com", "abc+xyz2@g.com", "xyz-team@goog.com"};
+    // Create accounts with given user name, first and second email addresses.
+    for (int i = 0; i < users.length; i++) {
+      accountCreator.create(users[i], emails1[i], "FullName " + users[i]).getId();
+      EmailInput input = new EmailInput();
+      input.email = emails2[i];
+      input.noConfirmation = true;
+      gApi.accounts().id(users[i]).addEmail(input);
+    }
+    // Find accounts with given first and second email addresses.
+    // OwnersDb uses either emails.getAccountFor or getAccountsFor to get preferred email addresses.
+    Multimap<String, Account.Id> map1 = emails.getAccountsFor(emails1);
+    Multimap<String, Account.Id> map2 = emails.getAccountsFor(emails2);
+    for (int i = 0; i < users.length; i++) {
+      Collection<Account.Id> ids1 = emails.getAccountFor(emails1[i]);
+      Collection<Account.Id> ids2 = emails.getAccountFor(emails2[i]);
+      Collection<Account.Id> ids3 = map1.get(emails1[i]);
+      Collection<Account.Id> ids4 = map2.get(emails2[i]);
+      assertThat(ids1).hasSize(1);
+      assertThat(ids2).hasSize(1);
+      assertThat(ids3).hasSize(1);
+      assertThat(ids4).hasSize(1);
+      Account.Id id1 = ids1.iterator().next();
+      Account.Id id2 = ids2.iterator().next();
+      Account.Id id3 = ids3.iterator().next();
+      Account.Id id4 = ids4.iterator().next();
+      assertThat(id1).isEqualTo(id2); // Both emails should find the same account.
+      assertThat(id1).isEqualTo(id3);
+      assertThat(id1).isEqualTo(id4);
+      // Action.getReviewers and Checker.getVotes use accountCache to get email address.
+      assertThat(accountCache.get(id1).getAccount().getPreferredEmail()).isEqualTo(emails1[i]);
+    }
+    // Wrong or non-existing email address.
+    String[] wrongEmails = {"nobody", "@g.com", "nobody@g.com", "*"};
+    Multimap<String, Account.Id> email2ids = emails.getAccountsFor(wrongEmails);
+    for (String email : wrongEmails) {
+      assertThat(emails.getAccountFor(email)).isEmpty();
+      assertThat(email2ids.get(email)).isEmpty();
     }
   }
 
@@ -220,9 +259,9 @@
     PushOneCommit.Result cB = createChange("add tB.c", "tB.c", "Hello B!");
 
     // Default owners file name is "OWNERS".
-    assertThat(Config.getOwnersFileName(null)).isEqualTo("OWNERS");
-    assertThat(Config.getOwnersFileName(pA)).isEqualTo("OWNERS");
-    assertThat(Config.getOwnersFileName(pB)).isEqualTo("OWNERS");
+    assertThat(Config.getOwnersFileName(null, null)).isEqualTo("OWNERS");
+    assertThat(Config.getOwnersFileName(pA, null)).isEqualTo("OWNERS");
+    assertThat(Config.getOwnersFileName(pB, null)).isEqualTo("OWNERS");
 
     String ownerX = oneOwnerList("x@x");
     String ownerY = oneOwnerList("y@y");
@@ -234,8 +273,8 @@
     setProjectConfig("ownersFileName", "OWNERS.alpha");
     switchProject(pB);
     setProjectConfig("ownersFileName", "OWNERS.beta");
-    assertThat(Config.getOwnersFileName(pA)).isEqualTo("OWNERS.alpha");
-    assertThat(Config.getOwnersFileName(pB)).isEqualTo("OWNERS.beta");
+    assertThat(Config.getOwnersFileName(pA, null)).isEqualTo("OWNERS.alpha");
+    assertThat(Config.getOwnersFileName(pB, null)).isEqualTo("OWNERS.beta");
     String ownerA = oneOwnerList("a@a");
     String ownerB = oneOwnerList("b@b");
     assertThat(getOwnersResponse(cA)).contains(ownerA + ", files:[ tA.c ]");
@@ -244,24 +283,38 @@
     // Change back to OWNERS in Project_A
     switchProject(pA);
     setProjectConfig("ownersFileName", "OWNERS");
-    assertThat(Config.getOwnersFileName(pA)).isEqualTo("OWNERS");
+    assertThat(Config.getOwnersFileName(pA, null)).isEqualTo("OWNERS");
     assertThat(getOwnersResponse(cA)).contains(ownerX + ", files:[ tA.c ]");
     assertThat(getOwnersResponse(cB)).contains(ownerB + ", files:[ tB.c ]");
 
     // Change back to OWNERS.alpha in Project_B, but there is no OWNERS.alpha
     switchProject(pB);
     setProjectConfig("ownersFileName", "OWNERS.alpha");
-    assertThat(Config.getOwnersFileName(pB)).isEqualTo("OWNERS.alpha");
+    assertThat(Config.getOwnersFileName(pB, null)).isEqualTo("OWNERS.alpha");
     assertThat(getOwnersResponse(cA)).contains(ownerX + ", files:[ tA.c ]");
     assertThat(getOwnersResponse(cB)).contains("owners:[], files:[ tB.c ]");
 
     // Do not accept empty string or all-white-spaces for ownersFileName.
     setProjectConfig("ownersFileName", "   ");
-    assertThat(Config.getOwnersFileName(pB)).isEqualTo("OWNERS");
+    assertThat(Config.getOwnersFileName(pB, null)).isEqualTo("OWNERS");
     setProjectConfig("ownersFileName", " \t  ");
-    assertThat(Config.getOwnersFileName(pB)).isEqualTo("OWNERS");
+    assertThat(Config.getOwnersFileName(pB, null)).isEqualTo("OWNERS");
     setProjectConfig("ownersFileName", "O");
-    assertThat(Config.getOwnersFileName(pB)).isEqualTo("O");
+    assertThat(Config.getOwnersFileName(pB, null)).isEqualTo("O");
+  }
+
+  @Test
+  public void authorDefaultVoteTest() throws Exception {
+    // CL author has default +1 owner vote.
+    addFile("add d1/OWNERS", "d1/OWNERS", user.email + "\n"); // d1 owned by user
+    addFile("add d2/OWNERS", "d2/OWNERS", admin.email + "\n"); // d2 owned by admin
+    // admin is the author of CLs created by createChange.
+    PushOneCommit.Result r1 = createChange("add d1/t.c", "d1/t.c", "Hello1");
+    PushOneCommit.Result r2 = createChange("add d2/t.c", "d2/t.c", "Hello2");
+    PushOneCommit.Result r3 = createChange("add d3/t.c", "d3/t.c", "Hello3");
+    assertThat(checkApproval(r1)).isEqualTo(-1); // owner is not change author
+    assertThat(checkApproval(r2)).isEqualTo(1); // owner is change author, default +1
+    assertThat(checkApproval(r3)).isEqualTo(0); // no owner is found in d3
   }
 
   @Test
@@ -273,7 +326,8 @@
     ChangeResource cr = parseChangeResource(changeInfo.changeId);
     Action.Parameters param = new Action.Parameters();
     Action action =
-        new Action("find-owners", null, null, null, changeDataFactory, accountCache, repoManager);
+        new Action(
+            "find-owners", null, null, null, changeDataFactory, accountCache, emails, repoManager);
     Response<RestResult> response = action.apply(db, cr, param);
     RestResult result = response.value();
     verifyRestResult(result, 1, 1, changeInfo._number, false);
@@ -381,6 +435,14 @@
     approveSubmit(commit);
   }
 
+  private int checkApproval(PushOneCommit.Result r) throws Exception {
+    Repository repo = repoManager.openRepository(r.getChange().project());
+    Cache cache = Cache.getInstance().init(0, 0);
+    OwnersDb db = cache.get(accountCache, emails, repo, r.getChange(), 1);
+    Checker c = new Checker(repo, r.getChange(), 1);
+    return c.findApproval(accountCache, db);
+  }
+
   // Remove '"' and space; replace '\n' with ' '; ignore unpredictable "owner_revision".
   private static String filteredJson(String json) {
     return json.replaceAll("[\" ]*", "").replace('\n', ' ').replaceAll("owner_revision:[^ ]* ", "");
diff --git a/src/test/java/com/googlesource/gerrit/plugins/findowners/OwnersValidatorTest.java b/src/test/java/com/googlesource/gerrit/plugins/findowners/OwnersValidatorTest.java
new file mode 100644
index 0000000..68e8b3d
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/findowners/OwnersValidatorTest.java
@@ -0,0 +1,317 @@
+// 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 static com.googlesource.gerrit.plugins.findowners.Config.OWNERS;
+import static com.googlesource.gerrit.plugins.findowners.Config.OWNERS_FILE_NAME;
+import static com.googlesource.gerrit.plugins.findowners.Config.REJECT_ERROR_IN_OWNERS;
+
+import com.google.common.base.Function;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSetMultimap;
+import com.google.common.collect.Lists;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.account.Emails;
+import com.google.gerrit.server.config.PluginConfig;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.apache.commons.io.FileUtils;
+import org.eclipse.jgit.api.AddCommand;
+import org.eclipse.jgit.api.Git;
+import org.eclipse.jgit.api.errors.GitAPIException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.storage.file.FileRepositoryBuilder;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Test OwnersValidator, which checks syntax of changed OWNERS files. */
+@RunWith(JUnit4.class)
+public class OwnersValidatorTest {
+
+  private static class MockedEmails extends Emails {
+    Set<String> registered;
+
+    MockedEmails() {
+      super(null, null);
+      registered =
+          ImmutableSet.of(
+              "u1@g.com", "u2@g.com", "u2.m@g.com", "user1@google.com", "u1+review@g.com");
+    }
+
+    @Override
+    public ImmutableSetMultimap<String, Account.Id> getAccountsFor(String... emails) {
+      // Used by checkEmails; each email should have exactly one Account.Id
+      ImmutableSetMultimap.Builder<String, Account.Id> builder = ImmutableSetMultimap.builder();
+      int id = 1000000;
+      for (String s : registered) {
+        builder.put(s, new Account.Id(++id));
+      }
+      return builder.build();
+    }
+  }
+
+  private File repoFolder;
+  private Repository repo;
+
+  @Before
+  public void init() throws IOException {
+    repoFolder = File.createTempFile("Git", "");
+    repoFolder.delete();
+    repo = FileRepositoryBuilder.create(new File(repoFolder, ".git"));
+    repo.create();
+  }
+
+  @After
+  public void cleanup() throws IOException {
+    repo.close();
+    if (repoFolder.exists()) {
+      FileUtils.deleteDirectory(repoFolder);
+    }
+  }
+
+  private static final String OWNERS_ANDROID = "OWNERS.android"; // alternative OWNERS file name
+  private static final PluginConfig ANDROID_CONFIG = createAndroidConfig(); // use OWNERS_ANDROID
+  private static final PluginConfig EMPTY_CONFIG = new PluginConfig("", new Config());
+  private static final PluginConfig ENABLED_CONFIG = createEnabledConfig(); // use OWNERS
+  private static final PluginConfig DISABLED_CONFIG = createDisabledConfig();
+
+  @Test
+  public void chekIsActiveAndFileName() throws Exception {
+    // This check should be enabled in project.config, default is not active.
+    assertThat(OwnersValidator.isActive(EMPTY_CONFIG)).isFalse();
+    assertThat(OwnersValidator.isActive(ENABLED_CONFIG)).isTrue();
+    assertThat(OwnersValidator.isActive(ANDROID_CONFIG)).isTrue();
+    assertThat(OwnersValidator.isActive(DISABLED_CONFIG)).isFalse();
+    // Default file name is "OWNERS".
+    assertThat(OwnersValidator.getOwnersFileName(EMPTY_CONFIG)).isEqualTo(OWNERS);
+    assertThat(OwnersValidator.getOwnersFileName(ENABLED_CONFIG)).isEqualTo(OWNERS);
+    assertThat(OwnersValidator.getOwnersFileName(DISABLED_CONFIG)).isEqualTo(OWNERS);
+    assertThat(OwnersValidator.getOwnersFileName(ANDROID_CONFIG)).isEqualTo(OWNERS_ANDROID);
+  }
+
+  private static final ImmutableMap<String, String> FILES_WITHOUT_OWNERS =
+      ImmutableMap.of("README", "any\n", "d1/test.c", "int x;\n");
+
+  @Test
+  public void testNoOwners() throws Exception {
+    try (RevWalk rw = new RevWalk(repo)) {
+      RevCommit c = makeCommit(rw, "Commit no OWNERS.", FILES_WITHOUT_OWNERS);
+      assertThat(validate(rw, c, false, ENABLED_CONFIG)).isEmpty();
+      assertThat(validate(rw, c, true, ENABLED_CONFIG)).isEmpty();
+    }
+  }
+
+  private static final ImmutableMap<String, String> FILES_WITH_NO_ERROR =
+      ImmutableMap.of(
+          OWNERS,
+          "\n\n#comments ...\n  ###  more comments\n"
+              + "   user1@google.com # comment\n"
+              + "u1+review@g.com###\n"
+              + " * # everyone can approve\n"
+              + "per-file *.py =  set  noparent###\n"
+              + "per-file   *.py=u2.m@g.com\n"
+              + "per-file *.txt = * # everyone can approve\n"
+              + "set  noparent  # comment\n");
+
+  private static final ImmutableSet<String> EXPECTED_VERBOSE_OUTPUT =
+      ImmutableSet.of(
+          "MSG: validate: " + OWNERS,
+          "MSG: owner: user1@google.com",
+          "MSG: owner: u1+review@g.com",
+          "MSG: owner: u2.m@g.com");
+
+  @Test
+  public void testGoodInput() throws Exception {
+    try (RevWalk rw = new RevWalk(repo)) {
+      RevCommit c = makeCommit(rw, "Commit good files", FILES_WITH_NO_ERROR);
+      assertThat(validate(rw, c, false, ENABLED_CONFIG)).isEmpty();
+      assertThat(validate(rw, c, true, ENABLED_CONFIG))
+          .containsExactlyElementsIn(EXPECTED_VERBOSE_OUTPUT);
+    }
+  }
+
+  private static final ImmutableMap<String, String> FILES_WITH_WRONG_SYNTAX =
+      ImmutableMap.of(
+          "README",
+          "# some content\nu2@g.com\n",
+          OWNERS,
+          "\nwrong syntax\n#comment\nuser1@google.com\n",
+          "d2/" + OWNERS,
+          "u1@g.com\nu3@g.com\n*\n",
+          "d3/" + OWNERS,
+          "\nfile: common/Owners\n");
+
+  private static final ImmutableSet<String> EXPECTED_WRONG_SYNTAX =
+      ImmutableSet.of(
+          "ERROR: syntax: " + OWNERS + ":2: wrong syntax",
+          "ERROR: unknown: u3@g.com at d2/" + OWNERS + ":2",
+          "ERROR: ignored: d3/" + OWNERS + ":2: file: common/Owners");
+
+  private static final ImmutableSet<String> EXPECTED_VERBOSE_WRONG_SYNTAX =
+      ImmutableSet.of(
+          "MSG: validate: d3/" + OWNERS,
+          "MSG: validate: d2/" + OWNERS,
+          "MSG: validate: " + OWNERS,
+          "MSG: owner: user1@google.com",
+          "MSG: owner: u1@g.com",
+          "MSG: owner: u3@g.com",
+          "ERROR: syntax: " + OWNERS + ":2: wrong syntax",
+          "ERROR: unknown: u3@g.com at d2/" + OWNERS + ":2",
+          "ERROR: ignored: d3/" + OWNERS + ":2: file: common/Owners");
+
+  @Test
+  public void testWrongSyntax() throws Exception {
+    try (RevWalk rw = new RevWalk(repo)) {
+      RevCommit c = makeCommit(rw, "Commit wrong syntax", FILES_WITH_WRONG_SYNTAX);
+      assertThat(validate(rw, c, false, ENABLED_CONFIG))
+          .containsExactlyElementsIn(EXPECTED_WRONG_SYNTAX);
+      assertThat(validate(rw, c, true, ENABLED_CONFIG))
+          .containsExactlyElementsIn(EXPECTED_VERBOSE_WRONG_SYNTAX);
+    }
+  }
+
+  private static final ImmutableMap<String, String> FILES_WITH_WRONG_EMAILS =
+      ImmutableMap.of("d1/" + OWNERS, "u1@g.com\n", "d2/" + OWNERS_ANDROID, "u2@g.com\n");
+
+  private static final ImmutableSet<String> EXPECTED_VERBOSE_DEFAULT =
+      ImmutableSet.of("MSG: validate: d1/" + OWNERS, "MSG: owner: u1@g.com");
+
+  private static final ImmutableSet<String> EXPECTED_VERBOSE_ANDROID =
+      ImmutableSet.of("MSG: validate: d2/" + OWNERS_ANDROID, "MSG: owner: u2@g.com");
+
+  @Test
+  public void checkWrongEmails() throws Exception {
+    try (RevWalk rw = new RevWalk(repo)) {
+      RevCommit c = makeCommit(rw, "Commit Default", FILES_WITH_WRONG_EMAILS);
+      assertThat(validate(rw, c, true, ENABLED_CONFIG))
+          .containsExactlyElementsIn(EXPECTED_VERBOSE_DEFAULT);
+    }
+  }
+
+  @Test
+  public void checkAndroidOwners() throws Exception {
+    try (RevWalk rw = new RevWalk(repo)) {
+      RevCommit c = makeCommit(rw, "Commit Android", FILES_WITH_WRONG_EMAILS);
+      assertThat(validate(rw, c, true, ANDROID_CONFIG))
+          .containsExactlyElementsIn(EXPECTED_VERBOSE_ANDROID);
+    }
+  }
+
+  private static PluginConfig createEnabledConfig() {
+    PluginConfig c = new PluginConfig("", new Config());
+    c.setBoolean(REJECT_ERROR_IN_OWNERS, true);
+    return c;
+  }
+
+  private static PluginConfig createDisabledConfig() {
+    PluginConfig c = new PluginConfig("", new Config());
+    c.setBoolean(REJECT_ERROR_IN_OWNERS, false);
+    return c;
+  }
+
+  private static PluginConfig createAndroidConfig() {
+    PluginConfig c = createEnabledConfig();
+    c.setString(OWNERS_FILE_NAME, OWNERS_ANDROID);
+    return c;
+  }
+
+  private RevCommit makeCommit(RevWalk rw, String message, Map<String, String> fileStrings)
+      throws IOException, GitAPIException {
+    Map<File, byte[]> fileBytes = new HashMap<>();
+    for (String path : fileStrings.keySet()) {
+      fileBytes.put(
+          new File(repo.getDirectory().getParent(), path),
+          fileStrings.get(path).getBytes(StandardCharsets.UTF_8));
+    }
+    return makeCommit(rw, repo, message, fileBytes);
+  }
+
+  private List<String> validate(RevWalk rw, RevCommit c, boolean verbose, PluginConfig cfg)
+      throws Exception {
+    MockedEmails myEmails = new MockedEmails();
+    OwnersValidator validator = new OwnersValidator(null, null, null, myEmails);
+    String ownersFileName = OwnersValidator.getOwnersFileName(cfg);
+    List<CommitValidationMessage> m = validator.performValidation(c, rw, ownersFileName, verbose);
+    return transformMessages(m);
+  }
+
+  private static String generateFilePattern(File f, Git git) {
+    return f.getAbsolutePath()
+        .replace(git.getRepository().getWorkTree().getAbsolutePath(), "")
+        .substring(1);
+  }
+
+  private static void addFiles(Git git, Map<File, byte[]> files)
+      throws IOException, GitAPIException {
+    AddCommand ac = git.add();
+    for (File f : files.keySet()) {
+      if (!f.exists()) {
+        FileUtils.touch(f);
+      }
+      if (files.get(f) != null) {
+        FileUtils.writeByteArrayToFile(f, files.get(f));
+      }
+      ac = ac.addFilepattern(generateFilePattern(f, git));
+    }
+    ac.call();
+  }
+
+  private static RevCommit makeCommit(
+      RevWalk rw, Repository repo, String message, Map<File, byte[]> files)
+      throws IOException, GitAPIException {
+    try (Git git = new Git(repo)) {
+      if (files != null) {
+        addFiles(git, files);
+      }
+      return rw.parseCommit(git.commit().setMessage(message).call());
+    }
+  }
+
+  private static List<String> transformMessages(List<CommitValidationMessage> messages) {
+    return Lists.transform(
+        messages,
+        new Function<CommitValidationMessage, String>() {
+          @Override
+          public String apply(CommitValidationMessage input) {
+            String pre = (input.isError()) ? "ERROR: " : "MSG: ";
+            return pre + input.getMessage();
+          }
+        });
+  }
+
+  @Test
+  public void testTransformer() {
+    List<CommitValidationMessage> messages = new LinkedList<>();
+    messages.add(new CommitValidationMessage("a message", false));
+    messages.add(new CommitValidationMessage("an error", true));
+    Set<String> expected = ImmutableSet.of("ERROR: an error", "MSG: a message");
+    assertThat(transformMessages(messages)).containsExactlyElementsIn(expected);
+  }
+}