1#!/usr/bin/env python3 2 3# Copyright 2020 The Pigweed Authors 4# 5# Licensed under the Apache License, Version 2.0 (the "License"); you may not 6# use this file except in compliance with the License. You may obtain a copy of 7# the License at 8# 9# https://www.apache.org/licenses/LICENSE-2.0 10# 11# Unless required by applicable law or agreed to in writing, software 12# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 13# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 14# License for the specific language governing permissions and limitations under 15# the License. 16"""Create transitive CLs for requirements on internal Gerrits. 17 18This is only intended to be used by Googlers. 19 20If the current CL needs to be tested alongside internal-project:1234 on an 21internal project, but "internal-project" is something that can't be referenced 22publicly, this automates creation of a CL on the pigweed-internal Gerrit that 23references internal-project:1234 so the current commit effectively has a 24requirement on internal-project:1234. 25 26For more see http://go/pigweed-ci-cq-intro. 27""" 28 29import argparse 30import logging 31from pathlib import Path 32import re 33import subprocess 34import sys 35import tempfile 36import uuid 37 38HELPER_GERRIT = 'pigweed-internal' 39HELPER_PROJECT = 'requires-helper' 40HELPER_REPO = 'sso://{}/{}'.format(HELPER_GERRIT, HELPER_PROJECT) 41 42# Subset of the output from pushing to Gerrit. 43DEFAULT_OUTPUT = f''' 44remote: 45remote: https://{HELPER_GERRIT}-review.git.corp.google.com/c/{HELPER_PROJECT}/+/123456789 DO NOT SUBMIT [NEW] 46remote: 47'''.strip() 48 49_LOG = logging.getLogger(__name__) 50 51 52def parse_args() -> argparse.Namespace: 53 """Creates an argument parser and parses arguments.""" 54 55 parser = argparse.ArgumentParser(description=__doc__) 56 parser.add_argument( 57 'requirements', 58 nargs='+', 59 help='Requirements to be added ("<gerrit-name>:<cl-number>").', 60 ) 61 parser.add_argument( 62 '--no-push', 63 dest='push', 64 action='store_false', 65 help=argparse.SUPPRESS, # This option is only for debugging. 66 ) 67 68 return parser.parse_args() 69 70 71def _run_command(*args, **kwargs): 72 kwargs.setdefault('capture_output', True) 73 _LOG.debug('%s', args) 74 _LOG.debug('%s', kwargs) 75 res = subprocess.run(*args, **kwargs) 76 _LOG.debug('%s', res.stdout) 77 _LOG.debug('%s', res.stderr) 78 res.check_returncode() 79 return res 80 81 82def check_status() -> bool: 83 res = subprocess.run(['git', 'status'], capture_output=True) 84 if res.returncode: 85 _LOG.error('repository not clean, commit to suppress this warning') 86 return False 87 return True 88 89 90def clone(requires_dir: Path) -> None: 91 _LOG.info('cloning helper repository into %s', requires_dir) 92 _run_command(['git', 'clone', HELPER_REPO, '.'], cwd=requires_dir) 93 94 95def create_commit(requires_dir: Path, requirements) -> None: 96 change_id = str(uuid.uuid4()).replace('-', '00') 97 _LOG.debug('change_id %s', change_id) 98 path = requires_dir / change_id 99 _LOG.debug('path %s', path) 100 with open(path, 'w'): 101 pass 102 103 _run_command(['git', 'add', path], cwd=requires_dir) 104 105 commit_message = [ 106 f'DO NOT SUBMIT {change_id[0:10]}', 107 '', 108 f'Change-Id: I{change_id}', 109 ] 110 for req in requirements: 111 commit_message.append(f'Requires: {req}') 112 113 _LOG.debug('message %s', commit_message) 114 _run_command( 115 ['git', 'commit', '-m', '\n'.join(commit_message)], 116 cwd=requires_dir, 117 ) 118 119 # Not strictly necessary, only used for logging. 120 _run_command(['git', 'show'], cwd=requires_dir) 121 122 123def push_commit(requires_dir: Path, push=True) -> str: 124 output = DEFAULT_OUTPUT 125 if push: 126 res = _run_command( 127 ['git', 'push', HELPER_REPO, '+HEAD:refs/for/master'], 128 cwd=requires_dir, 129 ) 130 output = res.stderr.decode() 131 132 _LOG.debug('output: %s', output) 133 regex = re.compile( 134 f'^\\s*remote:\\s*' 135 f'https://{HELPER_GERRIT}-review.(?:git.corp.google|googlesource).com/' 136 f'c/{HELPER_PROJECT}/\\+/(?P<num>\\d+)\\s+', 137 re.MULTILINE, 138 ) 139 _LOG.debug('regex %r', regex) 140 match = regex.search(output) 141 if not match: 142 raise ValueError(f"invalid output from 'git push': {output}") 143 change_num = match.group('num') 144 _LOG.info('created %s change %s', HELPER_PROJECT, change_num) 145 return f'{HELPER_GERRIT}:{change_num}' 146 147 148def amend_existing_change(change: str) -> None: 149 res = _run_command(['git', 'log', '-1', '--pretty=%B']) 150 original = res.stdout.rstrip().decode() 151 152 addition = f'Requires: {change}' 153 _LOG.info('adding "%s" to current commit message', addition) 154 message = '\n'.join((original, addition)) 155 _run_command(['git', 'commit', '--amend', '--message', message]) 156 157 158def run(requirements, push=True) -> int: 159 """Entry point for requires.""" 160 161 if not check_status(): 162 return -1 163 164 # Create directory for checking out helper repository. 165 with tempfile.TemporaryDirectory() as requires_dir_str: 166 requires_dir = Path(requires_dir_str) 167 # Clone into helper repository. 168 clone(requires_dir) 169 # Make commit with requirements from command line. 170 create_commit(requires_dir, requirements) 171 # Push that commit and save its number. 172 change = push_commit(requires_dir, push=push) 173 # Add dependency on newly pushed commit on current commit. 174 amend_existing_change(change) 175 176 return 0 177 178 179def main() -> int: 180 return run(**vars(parse_args())) 181 182 183if __name__ == '__main__': 184 try: 185 # If pw_cli is available, use it to initialize logs. 186 from pw_cli import log 187 188 log.install(logging.INFO) 189 except ImportError: 190 # If pw_cli isn't available, display log messages like a simple print. 191 logging.basicConfig(format='%(message)s', level=logging.INFO) 192 193 sys.exit(main()) 194