Use weighted random selection; add project reviewers

* Allow Rust reviewers to have different number (quota)
  of randomly assigned projects.
  * People owning K projects got their quota reduced by K.
* Add more project owners (reviewers).
* Sort a set of reviewers before adding all of them.
* Add and separate unit tests.
  * Add TEST_MAPPING with the reviewers presubmit test.
  * Separate reviewers test in its own file;
    old external_updater test does not run with atest or TH.

Bug: 162988933
Test: out/host/*/testcases/*/*/external_updater_test
Test: out/host/*/testcases/*/*/external_updater_reviewers_test
Test: out/.../bin/external_updater update --branch_and_commit --push_change <some_project>
Change-Id: I375bf3d216f213452aacbedf844d1a1015310e7b
diff --git a/Android.bp b/Android.bp
index 59bda78..e867089 100644
--- a/Android.bp
+++ b/Android.bp
@@ -69,13 +69,37 @@
     },
 }
 
+python_defaults {
+    name: "external_updater_defaults",
+    libs: [
+        "external_updater_lib",
+    ],
+    version: {
+        py2: {
+            enabled: false,
+        },
+        py3: {
+            enabled: true,
+        },
+    },
+}
+
 python_test_host {
     name: "external_updater_test",
+    defaults: ["external_updater_defaults"],
     main: "external_updater_test.py",
     srcs: [
         "external_updater_test.py",
     ],
-    libs: [
-        "external_updater_lib",
+    test_suites: ["general-tests"],
+}
+
+python_test_host {
+    name: "external_updater_reviewers_test",
+    defaults: ["external_updater_defaults"],
+    main: "external_updater_reviewers_test.py",
+    srcs: [
+        "external_updater_reviewers_test.py",
     ],
+    test_suites: ["general-tests"],
 }
diff --git a/TEST_MAPPING b/TEST_MAPPING
new file mode 100644
index 0000000..00c285b
--- /dev/null
+++ b/TEST_MAPPING
@@ -0,0 +1,9 @@
+{
+  "presubmit": [
+    // "external_updater_test" runs alone but not with atest
+    {
+      "name": "external_updater_reviewers_test",
+      "host": true
+    }
+  ]
+}
diff --git a/external_updater_reviewers_test.py b/external_updater_reviewers_test.py
new file mode 100644
index 0000000..e055296
--- /dev/null
+++ b/external_updater_reviewers_test.py
@@ -0,0 +1,141 @@
+# Copyright (C) 2020 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.
+"""Unit tests for external updater reviewers."""
+
+from typing import List, Mapping, Set
+import unittest
+
+import reviewers
+
+
+class ExternalUpdaterReviewersTest(unittest.TestCase):
+    """Unit tests for external updater reviewers."""
+
+    def setUp(self):
+        super().setUp()
+        # save constants in reviewers
+        self.saved_proj_reviewers = reviewers.PROJ_REVIEWERS
+        self.saved_rust_reviewers = reviewers.RUST_REVIEWERS
+        self.saved_rust_reviewer_list = reviewers.RUST_REVIEWER_LIST
+        self.saved_num_rust_projects = reviewers.NUM_RUST_PROJECTS
+        self.saved_rust_crate_owners = reviewers.RUST_CRATE_OWNERS
+
+    def tearDown(self):
+        super().tearDown()
+        # restore constants in reviewers
+        reviewers.PROJ_REVIEWERS = self.saved_proj_reviewers
+        reviewers.RUST_REVIEWERS = self.saved_rust_reviewers
+        reviewers.RUST_REVIEWER_LIST = self.saved_rust_reviewer_list
+        reviewers.NUM_RUST_PROJECTS = self.saved_num_rust_projects
+        reviewers.RUST_CRATE_OWNERS = self.saved_rust_crate_owners
+
+    def _collect_reviewers(self, num_runs, proj_path):
+        counters = {}
+        for _ in range(num_runs):
+            name = reviewers.find_reviewers(proj_path)
+            if name in counters:
+                counters[name] += 1
+            else:
+                counters[name] = 1
+        return counters
+
+    def test_reviewers_types(self):
+        # Check type of PROJ_REVIEWERS
+        self.assertIsInstance(reviewers.PROJ_REVIEWERS, Mapping)
+        for key, value in reviewers.PROJ_REVIEWERS.items():
+            self.assertIsInstance(key, str)
+            if isinstance(value, Set) or isinstance(value, List):
+                map(lambda x: self.assertIsInstance(x, str), value)
+            else:
+                self.assertIsInstance(value, str)
+        # Check element types of the reviewers list and map.
+        self.assertIsInstance(reviewers.RUST_REVIEWERS, Mapping)
+        for (x, n) in reviewers.RUST_REVIEWERS.items():
+            self.assertIsInstance(x, str)
+            self.assertIsInstance(n, int)
+
+    def test_reviewers_constants(self):
+        # There should be enough people in the reviewers pool.
+        self.assertGreaterEqual(len(reviewers.RUST_REVIEWERS), 3)
+        # The NUM_RUST_PROJECTS should not be too small.
+        self.assertGreaterEqual(reviewers.NUM_RUST_PROJECTS, 50)
+        self.assertGreaterEqual(reviewers.NUM_RUST_PROJECTS,
+                                len(reviewers.RUST_CRATE_OWNERS))
+        # chh@ should be in many projects and not in RUST_REVIEWER_LIST
+        self.assertGreaterEqual(len(reviewers.RUST_REVIEWER_LIST), 3)
+        self.assertNotIn("[email protected]", reviewers.RUST_REVIEWER_LIST)
+        self.assertIn("[email protected]", reviewers.RUST_REVIEWERS)
+        # Assume no project reviewers and recreate RUST_REVIEWER_LIST
+        reviewers.PROJ_REVIEWERS = {}
+        reviewers.RUST_REVIEWER_LIST = reviewers.create_rust_reviewer_list()
+        sum_projects = sum(reviewers.RUST_REVIEWERS.values())
+        self.assertEqual(sum_projects, len(reviewers.RUST_REVIEWER_LIST))
+        self.assertGreaterEqual(sum_projects, reviewers.NUM_RUST_PROJECTS)
+
+    def test_reviewers_randomness(self):
+        # Check random selection of reviewers.
+        # This might fail when the random.choice function is extremely unfair.
+        # With N * 20 tries, each reviewer should be picked at least twice.
+        # Assume no project reviewers and recreate RUST_REVIEWER_LIST
+        reviewers.PROJ_REVIEWERS = {}
+        reviewers.RUST_REVIEWER_LIST = reviewers.create_rust_reviewer_list()
+        num_tries = len(reviewers.RUST_REVIEWERS) * 20
+        counters = self._collect_reviewers(num_tries, "rust/crates/libc")
+        self.assertEqual(len(counters), len(reviewers.RUST_REVIEWERS))
+        map(lambda n: self.assertGreaterEqual(n, 10), counters.values())
+        self.assertEqual(sum(counters.values()), num_tries)
+
+    def test_project_reviewers(self):
+        # For specific projects, select only the specified reviewers.
+        reviewers.PROJ_REVIEWERS = {
+            "rust/crates/p1": "[email protected]",
+            "rust/crates/p_any": ["[email protected]", "[email protected]"],
+            "rust/crates/p_all": {"z@g", "[email protected]", "[email protected]"},
+        }
+        counters = self._collect_reviewers(20, "external/rust/crates/p1")
+        self.assertEqual(len(counters), 1)
+        self.assertTrue(counters["[email protected]"], 20)
+        counters = self._collect_reviewers(20, "external/rust/crates/p_any")
+        self.assertEqual(len(counters), 2)
+        self.assertGreater(counters["[email protected]"], 2)
+        self.assertGreater(counters["[email protected]"], 2)
+        self.assertTrue(counters["[email protected]"] + counters["[email protected]"], 20)
+        counters = self._collect_reviewers(20, "external/rust/crates/p_all")
+        # {x, y, z} reviewers should be sorted
+        self.assertEqual(counters["[email protected],[email protected],r=z@g"], 20)
+
+    def test_weighted_reviewers(self):
+        # Test create_rust_reviewer_list
+        reviewers.PROJ_REVIEWERS = {
+            "any_p1": "x@g",  # 1 for x@g
+            "any_p2": {"xyz", "x@g"},  # 1 for x@g, xyz is not a rust reviewer
+            "any_p3": {"abc", "x@g"},  # 0.5 for "abc" and "x@g"
+        }
+        reviewers.RUST_REVIEWERS = {
+            "x@g": 5,  # ceil(5 - 2.5) = 3
+            "abc": 2,  # ceil(2 - 0.5) = 2
+        }
+        reviewer_list = reviewers.create_rust_reviewer_list()
+        self.assertEqual(reviewer_list, ["x@g", "x@g", "x@g", "abc", "abc"])
+        # Error case: if nobody has project quota, reset everyone to 1.
+        reviewers.RUST_REVIEWERS = {
+            "x@g": 1,  # ceil(1 - 2.5) = -1
+            "abc": 0,  # ceil(0 - 0.5) = 0
+        }
+        reviewer_list = reviewers.create_rust_reviewer_list()
+        self.assertEqual(reviewer_list, ["x@g", "abc"])  # everyone got 1
+
+
+if __name__ == "__main__":
+    unittest.main(verbosity=2)
diff --git a/external_updater_test.py b/external_updater_test.py
index 32e2918..c274be5 100644
--- a/external_updater_test.py
+++ b/external_updater_test.py
@@ -13,11 +13,9 @@
 # limitations under the License.
 """Unit tests for external updater."""
 
-from typing import List, Mapping, Set
 import unittest
 
 import github_archive_updater
-import reviewers
 
 
 class ExternalUpdaterTest(unittest.TestCase):
@@ -45,63 +43,6 @@
         expected_url = prefix + "archive/ver-1.0.zip"
         self.assertEqual(url, expected_url)
 
-    def collect_reviewers(self, num_runs, proj_path):
-        counters = {}
-        for _ in range(num_runs):
-            name = reviewers.find_reviewers(proj_path)
-            if name in counters:
-                counters[name] += 1
-            else:
-                counters[name] = 1
-        return counters
-
-    def test_reviewers(self):
-        # There should be enough people in the reviewers pool.
-        self.assertGreaterEqual(len(reviewers.RUST_REVIEWERS), 3)
-        # Check element types of the reviewers list and map.
-        self.assertIsInstance(reviewers.RUST_REVIEWERS, List)
-        for x in reviewers.RUST_REVIEWERS:
-            self.assertIsInstance(x, str)
-        self.assertIsInstance(reviewers.PROJ_REVIEWERS, Mapping)
-        for key, value in reviewers.PROJ_REVIEWERS.items():
-            self.assertIsInstance(key, str)
-            if isinstance(value, Set) or isinstance(value, List):
-                for x in value:
-                    self.assertIsInstance(x, str)
-            else:
-                self.assertIsInstance(value, str)
-        # Check random selection of reviewers.
-        # This might fail when the random.choice function is extremely unfair.
-        # With N * 20 tries, each reviewer should be picked at least twice.
-        counters = self.collect_reviewers(len(reviewers.RUST_REVIEWERS) * 20,
-                                          "rust/crates/no_such_project")
-        self.assertEqual(len(counters), len(reviewers.RUST_REVIEWERS))
-        for key, value in counters.items():
-            self.assertGreaterEqual(value, 2)
-        # For specific projects, select only the specified reviewers.
-        saved_reviewers = reviewers.PROJ_REVIEWERS
-        reviewers.PROJ_REVIEWERS = {
-            "rust/crates/p1": "[email protected]",
-            "rust/crates/p_any": ["[email protected]", "[email protected]"],
-            "rust/crates/p_all": {"[email protected]", "[email protected]"},
-        }
-        counters = self.collect_reviewers(20, "external/rust/crates/p1")
-        self.assertEqual(len(counters), 1)
-        self.assertTrue(counters["[email protected]"], 20)
-        counters = self.collect_reviewers(20, "external/rust/crates/p_any")
-        self.assertEqual(len(counters), 2)
-        self.assertGreater(counters["[email protected]"], 2)
-        self.assertGreater(counters["[email protected]"], 2)
-        self.assertTrue(counters["[email protected]"] + counters["[email protected]"], 20)
-        counters = self.collect_reviewers(20, "external/rust/crates/p_all")
-        counted = 0
-        if "[email protected],[email protected]" in counters:
-            counted += counters["[email protected],[email protected]"]
-        if "[email protected],[email protected]" in counters:
-            counted += counters["[email protected],[email protected]"]
-        self.assertEqual(counted, 20)
-        reviewers.PROJ_REVIEWERS = saved_reviewers
-
 
 if __name__ == "__main__":
-    unittest.main()
+    unittest.main(verbosity=2)
diff --git a/reviewers.py b/reviewers.py
index 35ba323..40443fc 100644
--- a/reviewers.py
+++ b/reviewers.py
@@ -11,37 +11,137 @@
 # 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.
-"""Find main reviewers for git push command."""
+"""Find main reviewers for git push commands."""
 
+import math
 import random
 from typing import List, Mapping, Set, Union
 
-# Note that for randomly-pick-one reviewers, we put them in a List[str]
+# To randomly pick one of multiple reviewers, we put them in a List[str]
 # to work with random.choice efficiently.
-# For pick-all reviewers, use a Set[str] to distinguish with List[str].
+# To pick all of multiple reviewers, we use a Set[str].
 
 # A ProjMapping maps a project path string to
-# (1) a single reviewer email address as a string
-# (2) multiple reviewer email addresses in a List; one to be randomly picked
-# (3) multiple reviewer email addresses in a Set; all to be picked
+# (1) a single reviewer email address as a string, or
+# (2) a List of multiple reviewers to be randomly picked, or
+# (3) a Set of multiple reviewers to be all added.
 ProjMapping = Mapping[str, Union[str, List[str], Set[str]]]
 
-# Project specific reviewers.
-PROJ_REVIEWERS: ProjMapping = {
+# Upgrades of Rust futures-* crates should be reviewed/tested together,
+# not split to radomly different people. In additional to one rust-dev
+# reviewer, we need one crosvm-dev reviewer to check the impact to crosvm.
+RUST_FUTURES_REVIEWERS: Set[str] = {'[email protected]', '[email protected]'}
+
+# Rust crate owners (reviewers).
+RUST_CRATE_OWNERS: ProjMapping = {
     'rust/crates/aho-corasick': '[email protected]',
     'rust/crates/anyhow': '[email protected]',
-    # more could be added later
+    'rust/crates/bindgen': '[email protected]',
+    'rust/crates/bytes': '[email protected]',
+    'rust/crates/cexpr': '[email protected]',
+    'rust/crates/cfg-if': '[email protected]',
+    'rust/crates/clang-sys': '[email protected]',
+    'rust/crates/futures': RUST_FUTURES_REVIEWERS,
+    'rust/crates/futures-channel': RUST_FUTURES_REVIEWERS,
+    'rust/crates/futures-core': RUST_FUTURES_REVIEWERS,
+    'rust/crates/futures-executor': RUST_FUTURES_REVIEWERS,
+    'rust/crates/futures-io': RUST_FUTURES_REVIEWERS,
+    'rust/crates/futures-macro': RUST_FUTURES_REVIEWERS,
+    'rust/crates/futures-sink': RUST_FUTURES_REVIEWERS,
+    'rust/crates/futures-task': RUST_FUTURES_REVIEWERS,
+    'rust/crates/futures-util': RUST_FUTURES_REVIEWERS,
+    'rust/crates/glob': '[email protected]',
+    'rust/crates/lazycell': '[email protected]',
+    'rust/crates/lazy_static': '[email protected]',
+    'rust/crates/libloading': '[email protected]',
+    'rust/crates/log': '[email protected]',
+    'rust/crates/nom': '[email protected]',
+    'rust/crates/once_cell': '[email protected]',
+    'rust/crates/peeking_take_while': '[email protected]',
+    'rust/crates/pin-project': '[email protected]',
+    'rust/crates/pin-project-internal': '[email protected]',
+    'rust/crates/protobuf': '[email protected]',
+    'rust/crates/protobuf-codegen': '[email protected]',
+    'rust/crates/regex': '[email protected]',
+    'rust/crates/regex-syntax': '[email protected]',
+    'rust/crates/rustc-hash': '[email protected]',
+    'rust/crates/shlex': '[email protected]',
+    'rust/crates/thread_local': '[email protected]',
+    'rust/crates/which': '[email protected]',
+    # more rust crate owners could be added later
 }
 
+PROJ_REVIEWERS: ProjMapping = {
+    # define non-rust project reviewers here
+}
+
+# Combine all roject reviewers.
+PROJ_REVIEWERS.update(RUST_CRATE_OWNERS)
+
+# Estimated number of rust projects, not the actual number.
+# It is only used to make random distribution "fair" among RUST_REVIEWERS.
+# It should not be too small, to spread nicely to multiple reviewers.
+# It should be larger or equal to len(RUST_CRATES_OWNERS).
+NUM_RUST_PROJECTS = 90
+
 # Reviewers for external/rust/crates projects not found in PROJ_REVIEWER.
-RUST_REVIEWERS: List[str] = [
-    '[email protected]',
-    '[email protected]',
-    '[email protected]',
-    '[email protected]',
-    '[email protected]',
-    '[email protected]',
-]
+# Each person has a quota, the number of projects to review.
+# Sum of these numbers should be greater or equal to NUM_RUST_PROJECTS
+# to avoid error cases in the creation of RUST_REVIEWER_LIST.
+RUST_REVIEWERS: Mapping[str, int] = {
+    '[email protected]': 15,
+    '[email protected]': 15,
+    '[email protected]': 15,
+    '[email protected]': 15,
+    '[email protected]': 15,
+    '[email protected]': 15,
+    # If a Rust reviewer needs to take a vacation, comment out the line,
+    # and distribute the quota to other reviewers.
+}
+
+
+def add_proj_count(projects: Mapping[str, float], reviewer: str, n: float):
+    """Add n to the number of projects owned by the reviewer."""
+    if reviewer in projects:
+        projects[reviewer] += n
+    else:
+        projects[reviewer] = n
+
+
+# Random Rust reviewers are selected from RUST_REVIEWER_LIST,
+# which is created from RUST_REVIEWERS and PROJ_REVIEWERS.
+# A person P in RUST_REVIEWERS will occur in the RUST_REVIEWER_LIST N times,
+# if N = RUST_REVIEWERS[P] - (number of projects owned by P in PROJ_REVIEWERS)
+# is greater than 0. N is rounded up by math.ceil.
+def create_rust_reviewer_list() -> List[str]:
+    """Create a list of duplicated reviewers for weighted random selection."""
+    # Count number of projects owned by each reviewer.
+    rust_reviewers = set(RUST_REVIEWERS.keys())
+    projects = {}  # map from owner to number of owned projects
+    for value in PROJ_REVIEWERS.values():
+        if isinstance(value, str):  # single reviewer for a project
+            add_proj_count(projects, value, 1)
+            continue
+        # multiple reviewers share one project, count only rust_reviewers
+        reviewers = set(filter(lambda x: x in rust_reviewers, value))
+        if reviewers:
+            count = 1.0 / len(reviewers)  # shared among all reviewers
+            for name in reviewers:
+                add_proj_count(projects, name, count)
+    result = []
+    for (x, n) in RUST_REVIEWERS.items():
+        if x in projects:  # reduce x's quota by the number of assigned ones
+            n = n - projects[x]
+        if n > 0:
+            result.extend([x] * math.ceil(n))
+    if result:
+        return result
+    # Something was wrong or quotas were too small so that nobody
+    # was selected from the RUST_REVIEWERS. Select everyone!!
+    return list(RUST_REVIEWERS.keys())
+
+
+RUST_REVIEWER_LIST: List[str] = create_rust_reviewer_list()
 
 
 def find_reviewers(proj_path: str) -> str:
@@ -55,10 +155,10 @@
         reviewers = PROJ_REVIEWERS[proj_path]
         if isinstance(reviewers, List):  # pick any one reviewer
             return 'r=' + random.choice(reviewers)
-        if isinstance(reviewers, Set):  # add all reviewers
-            return ','.join(map(lambda x: 'r=' + x, reviewers))
+        if isinstance(reviewers, Set):  # add all reviewers in sorted order
+            return ','.join(map(lambda x: 'r=' + x, sorted(reviewers)))
         # reviewers must be a string
         return 'r=' + reviewers
     if proj_path.startswith('rust/crates/'):
-        return 'r=' + random.choice(RUST_REVIEWERS)
+        return 'r=' + random.choice(RUST_REVIEWER_LIST)
     return ''