diff options
| author | android-build-prod (mdb) <android-build-team-robot@google.com> | 2020-10-01 20:27:46 +0000 |
|---|---|---|
| committer | android-build-prod (mdb) <android-build-team-robot@google.com> | 2020-10-01 20:27:46 +0000 |
| commit | 272e482a688e3a3c952355ea78509778a6a76bec (patch) | |
| tree | 56686c23546d2954f93611c41df35c43060d4d09 | |
| parent | 88736e48ceab1443321a07bfad12ce8caf27b2c9 (diff) | |
| parent | 94febd0c23e60d8689ec7be85006034582582650 (diff) | |
| download | platform_tools_external_updater-sdk-release.tar.gz platform_tools_external_updater-sdk-release.tar.bz2 platform_tools_external_updater-sdk-release.zip | |
Snap for 6877830 from 94febd0c23e60d8689ec7be85006034582582650 to sdk-releasesdk-release
Change-Id: Icda8f3eb86ef6bd16ed4deb3d19bffd0db1eee55
| -rw-r--r-- | Android.bp | 32 | ||||
| -rw-r--r-- | PREUPLOAD.cfg | 5 | ||||
| -rw-r--r-- | TEST_MAPPING | 9 | ||||
| -rw-r--r-- | archive_utils.py | 2 | ||||
| -rw-r--r-- | base_updater.py | 6 | ||||
| -rw-r--r-- | crates_updater.py | 79 | ||||
| -rw-r--r-- | external_updater.py | 24 | ||||
| -rw-r--r-- | external_updater_reviewers_test.py | 148 | ||||
| -rw-r--r-- | external_updater_test.py | 5 | ||||
| -rw-r--r-- | fileutils.py | 14 | ||||
| -rw-r--r-- | git_utils.py | 17 | ||||
| -rw-r--r-- | github_archive_updater.py | 5 | ||||
| -rw-r--r-- | hashtags.py | 22 | ||||
| -rw-r--r-- | notifier.py | 16 | ||||
| -rwxr-xr-x | regen_bp.sh | 83 | ||||
| -rw-r--r-- | reviewers.py | 169 | ||||
| -rw-r--r-- | update_package.sh | 10 | ||||
| -rw-r--r-- | updater_utils.py | 2 |
18 files changed, 613 insertions, 35 deletions
@@ -27,7 +27,6 @@ python_binary_host { name: "external_updater_notifier", main: "notifier.py", srcs: [ - "git_utils.py", "notifier.py", ], } @@ -42,7 +41,9 @@ python_library_host { "git_updater.py", "git_utils.py", "github_archive_updater.py", + "hashtags.py", "metadata.proto", + "reviewers.py", "updater_utils.py", ], libs: [ @@ -54,6 +55,7 @@ python_library_host { }, data: [ "update_package.sh", + "regen_bp.sh", ], version: { py2: { @@ -67,13 +69,37 @@ python_library_host { }, } +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 @@ def get_extract_func(url): 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 @@ class Updater: 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..ba8f16f 100644 --- a/crates_updater.py +++ b/crates_updater.py @@ -19,18 +19,27 @@ 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) + class CratesUpdater(Updater): """Updater for crates.io packages.""" - dl_path: str + download_url: str package: str def is_supported_url(self) -> bool: @@ -42,16 +51,55 @@ class CratesUpdater(Updater): 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 +107,24 @@ class CratesUpdater(Updater): 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) -> 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) diff --git a/external_updater.py b/external_updater.py index 31cdbbd..7aa5729 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 git_updater import GitUpdater 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 @@ def _do_update(args: argparse.Namespace, updater: Updater, 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) + 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 @@ def check_and_update(args: argparse.Namespace, 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 @@ def parse_args() -> argparse.Namespace: '--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 @@ import github_archive_updater 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 @@ class ExternalUpdaterTest(unittest.TestCase): 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 @@ def read_metadata(proj_path: Path) -> metadata_pb2.MetaData: 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 @@ def write_metadata(proj_path: Path, metadata: metadata_pb2.MetaData) -> None: 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 @@ import subprocess 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 @@ def get_commits_ahead(proj_path: Path, branch: str, 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_branches(proj_path: Path, remote_name: str) -> List[str]: 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_PATTERN = r'^[a-f0-9]{40}$' 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 checkout(proj_path: Path, branch_name: str) -> None: 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 @@ from typing import List, Optional, Tuple 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 @@ class GithubArchiveUpdater(Updater): 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 @@ class GithubArchiveUpdater(Updater): 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 @@ class GithubArchiveUpdater(Updater): 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 re import subprocess import time -import git_utils - +# pylint: disable=invalid-name def parse_args(): """Parses commandline arguments.""" @@ -96,6 +95,15 @@ def _send_email(proj, latest_ver, recipient, upgrade_log): 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 @@ def _should_notify(latest_ver, proj_history): 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 send_notification(args): 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 @@ def _check_updates(args): 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..df319a5 --- /dev/null +++ b/regen_bp.sh @@ -0,0 +1,83 @@ +#!/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/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 @@ set -e 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 "post_update.sh" 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 pathlib import Path from typing import List, Tuple, Type from base_updater import Updater +# pylint: disable=import-error import metadata_pb2 # type: ignore @@ -80,6 +81,7 @@ def _parse_version(version: str) -> ParsedVersion: 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.') |
