pw_cli: Script for generating 'Requires:' CLs
Add script for generating transitive 'Requires:' CLs to unnamed internal
Gerrit instances. For now putting into pw_cli because this doesn't seem
to need its own module and there's nowhere better to put it.
Change-Id: I9837a37c593c24a4839ab8523fdc3a5752b40206
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/28280
Commit-Queue: Rob Mohr <mohrr@google.com>
Reviewed-by: Keir Mierle <keir@google.com>
diff --git a/PW_PLUGINS b/PW_PLUGINS
index fdba3b5..e51f00a 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 9bf6ec6..43d3909 100644
--- a/pw_cli/py/BUILD.gn
+++ b/pw_cli/py/BUILD.gn
@@ -30,6 +30,7 @@
"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 0000000..5ff121f
--- /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 ("<gerrit-name>:<cl-number>").',
+ )
+ 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<num>\\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())