Skip ab/6749736 in stage.
Merged-In: Ica61bbf83dd6bfa47cd3d1b84986f7cbadde1b7a
Change-Id: I575e0f9bef1f12cd790475e3fdee7e47aba551e5
diff --git a/Android.bp b/Android.bp
index 960d19c..532bb46 100644
--- a/Android.bp
+++ b/Android.bp
@@ -27,7 +27,6 @@
name: "external_updater_notifier",
main: "notifier.py",
srcs: [
- "git_utils.py",
"notifier.py",
],
}
@@ -42,7 +41,9 @@
"git_updater.py",
"git_utils.py",
"github_archive_updater.py",
+ "hashtags.py",
"metadata.proto",
+ "reviewers.py",
"updater_utils.py",
],
libs: [
@@ -54,6 +55,7 @@
},
data: [
"update_package.sh",
+ "regen_bp.sh",
],
version: {
py2: {
@@ -67,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/PREUPLOAD.cfg b/PREUPLOAD.cfg
index f52b2bd..9e97695 100644
--- a/PREUPLOAD.cfg
+++ b/PREUPLOAD.cfg
@@ -1,5 +1,2 @@
[Builtin Hooks]
-pylint = true
-
-[Builtin Hooks Options]
-pylint = --executable-path pylint3 ${PREUPLOAD_FILES}
+pylint3 = true
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/archive_utils.py b/archive_utils.py
index 4a10392..b8386aa 100644
--- a/archive_utils.py
+++ b/archive_utils.py
@@ -90,7 +90,7 @@
return func
# crates.io download url does not have file suffix
# e.g., https://crates.io/api/v1/crates/syn/1.0.16/download
- if url.find('/crates.io/api/') > 0:
+ if url.find('/crates.io/api/') > 0 or url.find('/static.crates.io/crates/'):
return untar
return None
diff --git a/base_updater.py b/base_updater.py
index 74b688d..8cf3255 100644
--- a/base_updater.py
+++ b/base_updater.py
@@ -16,6 +16,7 @@
from pathlib import Path
import fileutils
+# pylint: disable=import-error
import metadata_pb2 # type: ignore
@@ -70,3 +71,8 @@
def latest_url(self) -> metadata_pb2.URL:
"""Gets URL for latest version."""
return self._new_url
+
+ def use_current_as_latest(self):
+ """Uses current version/url as the latest to refresh project."""
+ self._new_ver = self._old_ver
+ self._new_url = self._old_url
diff --git a/crates_updater.py b/crates_updater.py
index 77fec67..78fa9b5 100644
--- a/crates_updater.py
+++ b/crates_updater.py
@@ -14,23 +14,39 @@
"""Module to check updates from crates.io."""
import json
+import os
+# pylint: disable=g-importing-member
+from pathlib import Path
import re
import urllib.request
import archive_utils
from base_updater import Updater
+# pylint: disable=import-error
import metadata_pb2 # type: ignore
import updater_utils
-CRATES_IO_URL_PATTERN: str = (r'^https:\/\/crates.io\/crates\/([-\w]+)')
+CRATES_IO_URL_PATTERN: str = (r"^https:\/\/crates.io\/crates\/([-\w]+)")
CRATES_IO_URL_RE: re.Pattern = re.compile(CRATES_IO_URL_PATTERN)
+ALPHA_BETA_PATTERN: str = (r"^.*[0-9]+\.[0-9]+\.[0-9]+-(alpha|beta).*")
+
+ALPHA_BETA_RE: re.Pattern = re.compile(ALPHA_BETA_PATTERN)
+
+VERSION_PATTERN: str = (r"([0-9]+)\.([0-9]+)\.([0-9]+)")
+
+VERSION_MATCHER: re.Pattern = re.compile(VERSION_PATTERN)
+
+DESCRIPTION_PATTERN: str = (r"^description *= *(\".+\")")
+
+DESCRIPTION_MATCHER: re.Pattern = re.compile(DESCRIPTION_PATTERN)
+
class CratesUpdater(Updater):
"""Updater for crates.io packages."""
- dl_path: str
+ download_url: str
package: str
def is_supported_url(self) -> bool:
@@ -42,16 +58,55 @@
self.package = match.group(1)
return True
+ def _get_version_numbers(self, version: str) -> (int, int, int):
+ match = VERSION_MATCHER.match(version)
+ if match is not None:
+ return tuple(int(match.group(i)) for i in range(1, 4))
+ return (0, 0, 0)
+
+ def _is_newer_version(self, prev_version: str, prev_id: int,
+ check_version: str, check_id: int):
+ """Return true if check_version+id is newer than prev_version+id."""
+ return ((self._get_version_numbers(check_version), check_id) >
+ (self._get_version_numbers(prev_version), prev_id))
+
+ def _find_latest_non_test_version(self) -> None:
+ url = "https://crates.io/api/v1/crates/{}/versions".format(self.package)
+ with urllib.request.urlopen(url) as request:
+ data = json.loads(request.read().decode())
+ last_id = 0
+ self._new_ver = ""
+ for v in data["versions"]:
+ version = v["num"]
+ if (not v["yanked"] and not ALPHA_BETA_RE.match(version) and
+ self._is_newer_version(
+ self._new_ver, last_id, version, int(v["id"]))):
+ last_id = int(v["id"])
+ self._new_ver = version
+ self.download_url = "https://crates.io" + v["dl_path"]
+
def check(self) -> None:
"""Checks crates.io and returns whether a new version is available."""
url = "https://crates.io/api/v1/crates/" + self.package
with urllib.request.urlopen(url) as request:
data = json.loads(request.read().decode())
self._new_ver = data["crate"]["max_version"]
- url = url + "/" + self._new_ver
- with urllib.request.urlopen(url) as request:
- data = json.loads(request.read().decode())
- self.dl_path = data["version"]["dl_path"]
+ # Skip d.d.d-{alpha,beta}* versions
+ if ALPHA_BETA_RE.match(self._new_ver):
+ print("Ignore alpha or beta release: {}-{}."
+ .format(self.package, self._new_ver))
+ self._find_latest_non_test_version()
+ else:
+ url = url + "/" + self._new_ver
+ with urllib.request.urlopen(url) as request:
+ data = json.loads(request.read().decode())
+ self.download_url = "https://crates.io" + data["version"]["dl_path"]
+
+ def use_current_as_latest(self):
+ Updater.use_current_as_latest(self)
+ # A shortcut to use the static download path.
+ self.download_url = "https://static.crates.io/crates/{}/{}-{}.crate".format(
+ self.package, self.package, self._new_ver)
def update(self) -> None:
"""Updates the package.
@@ -59,9 +114,52 @@
Has to call check() before this function.
"""
try:
- url = 'https://crates.io' + self.dl_path
- temporary_dir = archive_utils.download_and_extract(url)
+ temporary_dir = archive_utils.download_and_extract(self.download_url)
package_dir = archive_utils.find_archive_root(temporary_dir)
updater_utils.replace_package(package_dir, self._proj_path)
finally:
urllib.request.urlcleanup()
+
+ # pylint: disable=no-self-use
+ def update_metadata(self, metadata: metadata_pb2.MetaData,
+ full_path: Path) -> None:
+ """Updates METADATA content."""
+ # copy only HOMEPAGE url, and then add new ARCHIVE url.
+ new_url_list = []
+ for url in metadata.third_party.url:
+ if url.type == metadata_pb2.URL.HOMEPAGE:
+ new_url_list.append(url)
+ new_url = metadata_pb2.URL()
+ new_url.type = metadata_pb2.URL.ARCHIVE
+ new_url.value = "https://static.crates.io/crates/{}/{}-{}.crate".format(
+ metadata.name, metadata.name, metadata.third_party.version)
+ new_url_list.append(new_url)
+ del metadata.third_party.url[:]
+ metadata.third_party.url.extend(new_url_list)
+ # copy description from Cargo.toml to METADATA
+ cargo_toml = os.path.join(full_path, "Cargo.toml")
+ description = self._get_cargo_description(cargo_toml)
+ if description and description != metadata.description:
+ print("New METADATA description:", description)
+ metadata.description = description
+
+ def _toml2str(self, line: str) -> str:
+ """Convert a quoted toml string to a Python str without quotes."""
+ if line.startswith("\"\"\""):
+ return "" # cannot handle broken multi-line description
+ # TOML string escapes: \b \t \n \f \r \" \\ (no unicode escape)
+ line = line[1:-1].replace("\\\\", "\n").replace("\\b", "")
+ line = line.replace("\\t", " ").replace("\\n", " ").replace("\\f", " ")
+ line = line.replace("\\r", "").replace("\\\"", "\"").replace("\n", "\\")
+ # replace a unicode quotation mark, used in the libloading crate
+ return line.replace("’", "'").strip()
+
+ def _get_cargo_description(self, cargo_toml: str) -> str:
+ """Return the description in Cargo.toml or empty string."""
+ if os.path.isfile(cargo_toml) and os.access(cargo_toml, os.R_OK):
+ with open(cargo_toml, "r") as toml_file:
+ for line in toml_file:
+ match = DESCRIPTION_MATCHER.match(line)
+ if match:
+ return self._toml2str(match.group(1))
+ return ""
diff --git a/external_updater.py b/external_updater.py
index 31cdbbd..006ad34 100644
--- a/external_updater.py
+++ b/external_updater.py
@@ -17,6 +17,7 @@
Example usage:
updater.sh checkall
updater.sh update kotlinc
+updater.sh update --refresh --keep_date rust/crates/libc
"""
import argparse
@@ -35,6 +36,7 @@
from github_archive_updater import GithubArchiveUpdater
import fileutils
import git_utils
+# pylint: disable=import-error
import metadata_pb2 # type: ignore
import updater_utils
@@ -105,11 +107,15 @@
for metadata_url in updated_metadata.third_party.url:
if metadata_url == updater.current_url:
metadata_url.CopyFrom(updater.latest_url)
- fileutils.write_metadata(full_path, updated_metadata)
+ # For Rust crates, replace GIT url with ARCHIVE url
+ if isinstance(updater, CratesUpdater):
+ updater.update_metadata(updated_metadata, full_path)
+ fileutils.write_metadata(full_path, updated_metadata, args.keep_date)
git_utils.add_file(full_path, 'METADATA')
if args.branch_and_commit:
- msg = 'Upgrade {} to {}\n'.format(args.path, updater.latest_version)
+ msg = 'Upgrade {} to {}\n\nTest: make\n'.format(
+ args.path, updater.latest_version)
git_utils.add_file(full_path, '*')
git_utils.commit(full_path, msg)
@@ -149,9 +155,13 @@
else:
print(color_string(' Up to date.', Color.FRESH))
- if update_lib and (has_new_version or args.force):
+ if update_lib and args.refresh:
+ print('Refreshing the current version')
+ updater.use_current_as_latest()
+ if update_lib and (has_new_version or args.force or args.refresh):
_do_update(args, updater, metadata)
return updater
+ # pylint: disable=broad-except
except Exception as err:
print('{} {}.'.format(color_string('Failed.', Color.ERROR), err))
return str(err)
@@ -237,6 +247,14 @@
'--force',
help='Run update even if there\'s no new version.',
action='store_true')
+ update_parser.add_argument(
+ '--refresh',
+ help='Run update and refresh to the current version.',
+ action='store_true')
+ update_parser.add_argument(
+ '--keep_date',
+ help='Run update and do not change date in METADATA.',
+ action='store_true')
update_parser.add_argument('--branch_and_commit',
action='store_true',
help='Starts a new branch and commit changes.')
diff --git a/external_updater_reviewers_test.py b/external_updater_reviewers_test.py
new file mode 100644
index 0000000..f9c2014
--- /dev/null
+++ b/external_updater_reviewers_test.py
@@ -0,0 +1,148 @@
+# 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
+
+ # pylint: disable=no-self-use
+ 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 the types of PROJ_REVIEWERS and RUST_REVIEWERS."""
+ # Check type of PROJ_REVIEWERS
+ self.assertIsInstance(reviewers.PROJ_REVIEWERS, Mapping)
+ for key, value in reviewers.PROJ_REVIEWERS.items():
+ self.assertIsInstance(key, str)
+ # pylint: disable=isinstance-second-argument-not-valid-type
+ # https://github.com/PyCQA/pylint/issues/3507
+ if isinstance(value, (List, Set)):
+ for x in value:
+ self.assertIsInstance(x, str)
+ else:
+ self.assertIsInstance(value, str)
+ # Check element types of the reviewers list and map.
+ self.assertIsInstance(reviewers.RUST_REVIEWERS, Mapping)
+ for (name, quota) in reviewers.RUST_REVIEWERS.items():
+ self.assertIsInstance(name, str)
+ self.assertIsInstance(quota, int)
+
+ def test_reviewers_constants(self):
+ """Check the constants associated to the reviewers."""
+ # 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("chh@google.com", reviewers.RUST_REVIEWER_LIST)
+ self.assertIn("chh@google.com", 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))
+ for n in counters.values():
+ self.assertGreaterEqual(n, 10)
+ 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": "x@g.com",
+ "rust/crates/p_any": ["x@g.com", "y@g.com"],
+ "rust/crates/p_all": {"z@g", "x@g.com", "y@g.com"},
+ }
+ counters = self._collect_reviewers(20, "external/rust/crates/p1")
+ self.assertEqual(len(counters), 1)
+ self.assertTrue(counters["r=x@g.com"], 20)
+ counters = self._collect_reviewers(20, "external/rust/crates/p_any")
+ self.assertEqual(len(counters), 2)
+ self.assertGreater(counters["r=x@g.com"], 2)
+ self.assertGreater(counters["r=y@g.com"], 2)
+ self.assertTrue(counters["r=x@g.com"] + counters["r=y@g.com"], 20)
+ counters = self._collect_reviewers(20, "external/rust/crates/p_all")
+ # {x, y, z} reviewers should be sorted
+ self.assertEqual(counters["r=x@g.com,r=y@g.com,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 b834fed..c274be5 100644
--- a/external_updater_test.py
+++ b/external_updater_test.py
@@ -20,6 +20,7 @@
class ExternalUpdaterTest(unittest.TestCase):
"""Unit tests for external updater."""
+
def test_url_selection(self):
"""Tests that GithubArchiveUpdater can choose the right url."""
prefix = "https://github.com/author/project/"
@@ -43,5 +44,5 @@
self.assertEqual(url, expected_url)
-if __name__ == '__main__':
- unittest.main()
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/fileutils.py b/fileutils.py
index d7dd0fa..f6a51cc 100644
--- a/fileutils.py
+++ b/fileutils.py
@@ -66,7 +66,7 @@
return text_format.Parse(metadata, metadata_pb2.MetaData())
-def write_metadata(proj_path: Path, metadata: metadata_pb2.MetaData) -> None:
+def write_metadata(proj_path: Path, metadata: metadata_pb2.MetaData, keep_date: bool) -> None:
"""Writes updated METADATA file for a project.
This function updates last_upgrade_date in metadata and write to the project
@@ -75,13 +75,15 @@
Args:
proj_path: Path to the project.
metadata: The MetaData proto to write.
+ keep_date: Do not change date.
"""
- date = metadata.third_party.last_upgrade_date
- now = datetime.datetime.now()
- date.year = now.year
- date.month = now.month
- date.day = now.day
+ if not keep_date:
+ date = metadata.third_party.last_upgrade_date
+ now = datetime.datetime.now()
+ date.year = now.year
+ date.month = now.month
+ date.day = now.day
text_metadata = text_format.MessageToString(metadata)
with get_metadata_path(proj_path).open('w') as metadata_file:
metadata_file.write(text_metadata)
diff --git a/git_utils.py b/git_utils.py
index 69dcfba..0304417 100644
--- a/git_utils.py
+++ b/git_utils.py
@@ -19,6 +19,8 @@
from pathlib import Path
from typing import Dict, List, Tuple
+import hashtags
+import reviewers
def _run(cmd: List[str], cwd: Path) -> str:
"""Runs a command and returns its output."""
@@ -84,6 +86,7 @@
return out.splitlines()
+# pylint: disable=redefined-outer-name
def get_commit_time(proj_path: Path, commit: str) -> datetime.datetime:
"""Gets commit time of one commit."""
out = _run(['git', 'show', '-s', '--format=%ct', commit], cwd=proj_path)
@@ -103,11 +106,9 @@
def list_remote_tags(proj_path: Path, remote_name: str) -> List[str]:
"""Lists all tags for a remote."""
+ regex = re.compile(r".*refs/tags/(?P<tag>[^\^]*).*")
def parse_remote_tag(line: str) -> str:
- tag_prefix = 'refs/tags/'
- tag_suffix = '^{}'
- line = line[line.index(tag_prefix):]
- return line.lstrip(tag_prefix).rstrip(tag_suffix)
+ return regex.match(line).group("tag")
lines = _run(['git', "ls-remote", "--tags", remote_name],
cwd=proj_path).splitlines()
@@ -119,6 +120,7 @@
COMMIT_RE = re.compile(COMMIT_PATTERN)
+# pylint: disable=redefined-outer-name
def is_commit(commit: str) -> bool:
"""Whether a string looks like a SHA1 hash."""
return bool(COMMIT_RE.match(commit))
@@ -161,4 +163,9 @@
def push(proj_path: Path, remote_name: str) -> None:
"""Pushes change to remote."""
- _run(['git', 'push', remote_name, 'HEAD:refs/for/master'], cwd=proj_path)
+ cmd = ['git', 'push', remote_name, 'HEAD:refs/for/master']
+ if revs := reviewers.find_reviewers(str(proj_path)):
+ cmd.extend(['-o', revs])
+ if tag := hashtags.find_hashtag(proj_path):
+ cmd.extend(['-o', 't=' + tag])
+ _run(cmd, cwd=proj_path)
diff --git a/github_archive_updater.py b/github_archive_updater.py
index cc3840f..fc3e362 100644
--- a/github_archive_updater.py
+++ b/github_archive_updater.py
@@ -23,6 +23,7 @@
import archive_utils
from base_updater import Updater
import git_utils
+# pylint: disable=import-error
import metadata_pb2 # type: ignore
import updater_utils
@@ -88,6 +89,7 @@
return True
def _fetch_latest_release(self) -> Optional[Tuple[str, List[str]]]:
+ # pylint: disable=line-too-long
url = f'https://api.github.com/repos/{self.owner}/{self.repo}/releases/latest'
try:
with urllib.request.urlopen(url) as request:
@@ -109,6 +111,7 @@
for page in range(1, 21):
# Sleeps 10s to avoid rate limit.
time.sleep(10)
+ # pylint: disable=line-too-long
url = f'https://api.github.com/repos/{self.owner}/{self.repo}/tags?page={page}'
with urllib.request.urlopen(url) as request:
data = json.loads(request.read().decode())
@@ -133,11 +136,13 @@
def _fetch_latest_commit(self) -> None:
"""Checks upstream and gets the latest commit to master."""
+ # pylint: disable=line-too-long
url = f'https://api.github.com/repos/{self.owner}/{self.repo}/commits/master'
with urllib.request.urlopen(url) as request:
data = json.loads(request.read().decode())
self._new_ver = data['sha']
self._new_url.value = (
+ # pylint: disable=line-too-long
f'https://github.com/{self.owner}/{self.repo}/archive/{self._new_ver}.zip'
)
diff --git a/hashtags.py b/hashtags.py
new file mode 100644
index 0000000..9b043dd
--- /dev/null
+++ b/hashtags.py
@@ -0,0 +1,22 @@
+# 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.
+"""Find main reviewers for git push commands."""
+
+from pathlib import Path
+
+def find_hashtag(proj_path: Path) -> str:
+ """Returns an empty string or a hashtag for git push."""
+ if str(proj_path).find('/external/rust/') != -1:
+ return 'external_updater_rust'
+ return 'external_updater'
diff --git a/notifier.py b/notifier.py
index d061181..7e2e4c7 100644
--- a/notifier.py
+++ b/notifier.py
@@ -29,8 +29,7 @@
import subprocess
import time
-import git_utils
-
+# pylint: disable=invalid-name
def parse_args():
"""Parses commandline arguments."""
@@ -96,6 +95,15 @@
encoding='ascii')
+COMMIT_PATTERN = r'^[a-f0-9]{40}$'
+COMMIT_RE = re.compile(COMMIT_PATTERN)
+
+
+def is_commit(commit: str) -> bool:
+ """Whether a string looks like a SHA1 hash."""
+ return bool(COMMIT_RE.match(commit))
+
+
NOTIFIED_TIME_KEY_NAME = 'latest_notified_time'
@@ -106,7 +114,7 @@
timestamp = proj_history.get(NOTIFIED_TIME_KEY_NAME, 0)
time_diff = datetime.today() - datetime.fromtimestamp(timestamp)
- if git_utils.is_commit(latest_ver) and time_diff <= timedelta(days=30):
+ if is_commit(latest_ver) and time_diff <= timedelta(days=30):
return False
return True
@@ -156,6 +164,7 @@
def _upgrade(proj):
+ # pylint: disable=subprocess-run-check
out = subprocess.run([
'out/soong/host/linux-x86/bin/external_updater', 'update',
'--branch_and_commit', '--push_change', proj
@@ -188,6 +197,7 @@
params += args.paths
print(_get_android_top())
+ # pylint: disable=subprocess-run-check
subprocess.run(params, cwd=_get_android_top())
diff --git a/regen_bp.sh b/regen_bp.sh
new file mode 100755
index 0000000..2ade8e4
--- /dev/null
+++ b/regen_bp.sh
@@ -0,0 +1,84 @@
+#!/bin/bash
+#
+# 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.
+
+# This script is used by external_updater to replace a package. Don't
+# invoke directly.
+
+set -e
+
+# Call this in two ways:
+# (1) in a .../external/* rust directory with .bp and Cargo.toml,
+# development/scripts/cargo2android.py must be in PATH
+# (2) in a tmp new directory with .bp and Cargo.toml,
+# and $1 equals to the rust Android source tree root,
+# and $2 equals to the rust sub-directory path name under external.
+if [ "$1" == "" ]; then
+ external_dir=`pwd`
+ C2A=`which cargo2android.py`
+ if [ "$C2A" == "" ]; then
+ echo "ERROR: cannot find cargo2android.py in PATH"
+ exit 1
+ fi
+else
+ external_dir="$2" # e.g. rust/crates/bytes
+ C2A="$1/development/scripts/cargo2android.py"
+ if [ ! -f $C2A ]; then
+ echo "ERROR: cannot find $C2A"
+ exit 1
+ fi
+fi
+
+# Save Cargo.lock if it existed before this update.
+if [ -f Cargo.lock ]; then
+ mv Cargo.lock Cargo.lock.saved
+fi
+
+LINE1=`head -1 Android.bp`
+FLAGS=`echo $LINE1 | sed -e 's:^.*cargo2android.py ::;s:\.$::'`
+CMD="$C2A $FLAGS"
+echo "Updating Android.bp: $CMD"
+$CMD
+
+if [ -d $2/out ]; then
+ # copy files generated by cargo build to out directory
+ PKGNAME=`basename $2`
+ for f in $2/out/*
+ do
+ OUTF=`basename $f`
+ SRC=`ls ./target.tmp/x86_64-unknown-linux-gnu/debug/build/$PKGNAME-*/out/$OUTF ||
+ ls ./target.tmp/debug/build/$PKGNAME-*/out/$OUTF || true`
+ if [ "$SRC" != "" ]; then
+ echo "Copying $SRC to out/$OUTF"
+ mkdir -p out
+ cp $SRC out/$OUTF
+ fi
+ done
+fi
+rm -rf target.tmp cargo.out Cargo.lock
+
+# Restore Cargo.lock if it existed before this update.
+if [ -f Cargo.lock.saved ]; then
+ mv Cargo.lock.saved Cargo.lock
+fi
+
+# Some .bp files have manual changes that cannot be fixed by post_update.sh.
+# Add a note to force a manual edit.
+case $external_dir in
+ */libloading|*/libsqlite3-sys|*/unicode-xid)
+ echo "FIXME: Copy manual changes from old version!" >> Android.bp
+esac
+
+exit 0
diff --git a/reviewers.py b/reviewers.py
new file mode 100644
index 0000000..c6f48d6
--- /dev/null
+++ b/reviewers.py
@@ -0,0 +1,169 @@
+# 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.
+"""Find main reviewers for git push commands."""
+
+import math
+import random
+from typing import List, Mapping, Set, Union
+
+# To randomly pick one of multiple reviewers, we put them in a List[str]
+# to work with random.choice efficiently.
+# 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, 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]]]
+
+# 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] = {'chh@google.com', 'natsu@google.com'}
+
+# Rust crate owners (reviewers).
+RUST_CRATE_OWNERS: ProjMapping = {
+ 'rust/crates/aho-corasick': 'chh@google.com',
+ 'rust/crates/anyhow': 'mmaurer@google.com',
+ 'rust/crates/bindgen': 'chh@google.com',
+ 'rust/crates/bytes': 'chh@google.com',
+ 'rust/crates/cexpr': 'chh@google.com',
+ 'rust/crates/cfg-if': 'chh@google.com',
+ 'rust/crates/clang-sys': 'chh@google.com',
+ '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': 'chh@google.com',
+ 'rust/crates/lazycell': 'chh@google.com',
+ 'rust/crates/lazy_static': 'chh@google.com',
+ 'rust/crates/libloading': 'chh@google.com',
+ 'rust/crates/log': 'chh@google.com',
+ 'rust/crates/nom': 'chh@google.com',
+ 'rust/crates/once_cell': 'chh@google.com',
+ 'rust/crates/peeking_take_while': 'chh@google.com',
+ 'rust/crates/pin-project': 'chh@google.com',
+ 'rust/crates/pin-project-internal': 'chh@google.com',
+ 'rust/crates/protobuf': 'chh@google.com',
+ 'rust/crates/protobuf-codegen': 'chh@google.com',
+ 'rust/crates/regex': 'chh@google.com',
+ 'rust/crates/regex-syntax': 'chh@google.com',
+ 'rust/crates/rustc-hash': 'chh@google.com',
+ 'rust/crates/shlex': 'chh@google.com',
+ 'rust/crates/thread_local': 'chh@google.com',
+ 'rust/crates/which': 'chh@google.com',
+ # 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.
+# 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] = {
+ 'chh@google.com': 15,
+ 'ivanlozano@google.com': 15,
+ 'jeffv@google.com': 15,
+ 'jgalenson@google.com': 15,
+ 'mmaurer@google.com': 15,
+ 'srhines@google.com': 15,
+ 'tweek@google.com': 15,
+ # If a Rust reviewer needs to take a vacation, comment out the line,
+ # and distribute the quota to other reviewers.
+}
+
+
+# pylint: disable=invalid-name
+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
+ # pylint: disable=bad-builtin
+ 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:
+ """Returns an empty string or a reviewer parameter(s) for git push."""
+ index = proj_path.find('/external/')
+ if index >= 0: # full path
+ proj_path = proj_path[(index + len('/external/')):]
+ elif proj_path.startswith('external/'): # relative path
+ proj_path = proj_path[len('external/'):]
+ if proj_path in PROJ_REVIEWERS:
+ reviewers = PROJ_REVIEWERS[proj_path]
+ # pylint: disable=isinstance-second-argument-not-valid-type
+ if isinstance(reviewers, List): # pick any one reviewer
+ return 'r=' + random.choice(reviewers)
+ if isinstance(reviewers, Set): # add all reviewers in sorted order
+ # pylint: disable=bad-builtin
+ 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_REVIEWER_LIST)
+ return ''
diff --git a/update_package.sh b/update_package.sh
index d703466..663f647 100644
--- a/update_package.sh
+++ b/update_package.sh
@@ -22,6 +22,9 @@
tmp_dir=$1
external_dir=$2
+# root of Android source tree
+root_dir=`pwd`
+
echo "Entering $tmp_dir..."
cd $tmp_dir
@@ -47,6 +50,13 @@
CopyIfPresent "OWNERS"
CopyIfPresent "README.android"
+if [ -f $tmp_dir/Cargo.toml -a -f $tmp_dir/Android.bp ]
+then
+ # regenerate Android.bp before local patches, so it is
+ # possible to patch the generated Android.bp after this.
+ /bin/bash `dirname $0`/regen_bp.sh $root_dir $external_dir
+fi
+
echo "Applying patches..."
for p in $tmp_dir/patches/*.diff
do
diff --git a/updater_utils.py b/updater_utils.py
index 3301e8f..1bcd567 100644
--- a/updater_utils.py
+++ b/updater_utils.py
@@ -21,6 +21,7 @@
from typing import List, Tuple, Type
from base_updater import Updater
+# pylint: disable=import-error
import metadata_pb2 # type: ignore
@@ -80,6 +81,7 @@
versions = [int(v) for v in VERSION_SPLITTER_RE.split(version)]
return (versions, str(prefix), str(suffix))
except IndexError:
+ # pylint: disable=raise-missing-from
raise ValueError('Invalid version.')