diff --git a/PW_PLUGINS b/PW_PLUGINS index fdba3b5ab..e51f00ae4 100644 --- a/PW_PLUGINS +++ b/PW_PLUGINS @@ -11,7 +11,9 @@ # return an int to use as the exit code. # Pigweed's presubmit check script -presubmit pw_presubmit.pigweed_presubmit main heap-viewer pw_allocator.heap_viewer main rpc pw_hdlc.rpc_console main package pw_package.pigweed_packages main +presubmit pw_presubmit.pigweed_presubmit main +requires pw_cli.requires main +rpc pw_hdlc_lite.rpc_console main diff --git a/pw_cli/py/BUILD.gn b/pw_cli/py/BUILD.gn index 9bf6ec6e5..43d390974 100644 --- a/pw_cli/py/BUILD.gn +++ b/pw_cli/py/BUILD.gn @@ -30,6 +30,7 @@ pw_python_package("py") { "pw_cli/log.py", "pw_cli/plugins.py", "pw_cli/process.py", + "pw_cli/requires.py", ] pylintrc = "$dir_pigweed/.pylintrc" } diff --git a/pw_cli/py/pw_cli/requires.py b/pw_cli/py/pw_cli/requires.py new file mode 100755 index 000000000..5ff121fb1 --- /dev/null +++ b/pw_cli/py/pw_cli/requires.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 + +# Copyright 2020 The Pigweed Authors +# +# 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 +# +# https://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. +"""Create transitive CLs for requirements on internal Gerrits. + +This is only intended to be used by Googlers. + +If the current CL needs to be tested alongside internal-project:1234 on an +internal project, but "internal-project" is something that can't be referenced +publicly, this automates creation of a CL on the pigweed-internal Gerrit that +references internal-project:1234 so the current commit effectively has a +requirement on internal-project:1234. + +For more see http://go/pigweed-ci-cq-intro. +""" + +import argparse +import logging +from pathlib import Path +import re +import subprocess +import sys +import tempfile +import uuid + +HELPER_GERRIT = 'pigweed-internal' +HELPER_PROJECT = 'requires-helper' +HELPER_REPO = 'sso://{}/{}'.format(HELPER_GERRIT, HELPER_PROJECT) + +# Subset of the output from pushing to Gerrit. +DEFAULT_OUTPUT = f''' +remote: +remote: https://{HELPER_GERRIT}-review.git.corp.google.com/c/{HELPER_PROJECT}/+/123456789 DO NOT SUBMIT [NEW] +remote: +'''.strip() + +_LOG = logging.getLogger(__name__) + + +def parse_args() -> argparse.Namespace: + """Creates an argument parser and parses arguments.""" + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + 'requirements', + nargs='+', + help='Requirements to be added (":").', + ) + parser.add_argument( + '--no-push', + dest='push', + action='store_false', + help=argparse.SUPPRESS, # This option is only for debugging. + ) + + return parser.parse_args() + + +def _run_command(*args, **kwargs): + kwargs.setdefault('capture_output', True) + _LOG.debug('%s', args) + _LOG.debug('%s', kwargs) + res = subprocess.run(*args, **kwargs) + _LOG.debug('%s', res.stdout) + _LOG.debug('%s', res.stderr) + res.check_returncode() + return res + + +def check_status() -> bool: + res = subprocess.run(['git', 'status'], capture_output=True) + if res.returncode: + _LOG.error('repository not clean, commit to suppress this warning') + return False + return True + + +def clone(requires_dir: Path) -> None: + _LOG.info('cloning helper repository into %s', requires_dir) + _run_command(['git', 'clone', HELPER_REPO, '.'], cwd=requires_dir) + + +def create_commit(requires_dir: Path, requirements) -> None: + change_id = str(uuid.uuid4()).replace('-', '00') + _LOG.debug('change_id %s', change_id) + path = requires_dir / change_id + _LOG.debug('path %s', path) + with open(path, 'w'): + pass + + _run_command(['git', 'add', path], cwd=requires_dir) + + commit_message = [ + f'DO NOT SUBMIT {change_id[0:10]}', + '', + f'Change-Id: I{change_id}', + ] + for req in requirements: + commit_message.append(f'Requires: {req}') + + _LOG.debug('message %s', commit_message) + _run_command( + ['git', 'commit', '-m', '\n'.join(commit_message)], + cwd=requires_dir, + ) + + # Not strictly necessary, only used for logging. + _run_command(['git', 'show'], cwd=requires_dir) + + +def push_commit(requires_dir: Path, push=True) -> str: + output = DEFAULT_OUTPUT + if push: + res = _run_command( + ['git', 'push', HELPER_REPO, '+HEAD:refs/for/master'], + cwd=requires_dir, + ) + output = res.stderr.decode() + + _LOG.debug('output: %s', output) + regex = re.compile( + f'^\\s*remote:\\s*' + f'https://{HELPER_GERRIT}-review.(?:git.corp.google|googlesource).com/' + f'c/{HELPER_PROJECT}/\\+/(?P\\d+)\\s+', + re.MULTILINE, + ) + _LOG.debug('regex %r', regex) + match = regex.search(output) + if not match: + raise ValueError(f"invalid output from 'git push': {output}") + change_num = match.group('num') + _LOG.info('created %s change %s', HELPER_PROJECT, change_num) + return f'{HELPER_GERRIT}:{change_num}' + + +def amend_existing_change(change: str) -> None: + res = _run_command(['git', 'log', '-1', '--pretty=%B']) + original = res.stdout.rstrip().decode() + + addition = f'Requires: {change}' + _LOG.info('adding "%s" to current commit message', addition) + message = '\n'.join((original, addition)) + _run_command(['git', 'commit', '--amend', '--message', message]) + + +def run(requirements, push=True) -> int: + """Entry point for requires.""" + + if not check_status(): + return -1 + + # Create directory for checking out helper repository. + with tempfile.TemporaryDirectory() as requires_dir_str: + requires_dir = Path(requires_dir_str) + # Clone into helper repository. + clone(requires_dir) + # Make commit with requirements from command line. + create_commit(requires_dir, requirements) + # Push that commit and save its number. + change = push_commit(requires_dir, push=push) + # Add dependency on newly pushed commit on current commit. + amend_existing_change(change) + + return 0 + + +def main() -> int: + return run(**vars(parse_args())) + + +if __name__ == '__main__': + try: + # If pw_cli is available, use it to initialize logs. + from pw_cli import log + + log.install(logging.INFO) + except ImportError: + # If pw_cli isn't available, display log messages like a simple print. + logging.basicConfig(format='%(message)s', level=logging.INFO) + + sys.exit(main())