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>★</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(' '));
+ 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 + ' <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(' ' + 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
+ }
+}