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 json 31import logging 32from pathlib import Path 33import re 34import subprocess 35import sys 36import tempfile 37import uuid 38 39HELPER_GERRIT = 'pigweed-internal' 40HELPER_PROJECT = 'requires-helper' 41HELPER_REPO = 'sso://{}/{}'.format(HELPER_GERRIT, HELPER_PROJECT) 42 43# Pass checks that look for "DO NOT ..." and block submission. 44_DNS = ' '.join( 45 ( 46 'DO', 47 'NOT', 48 'SUBMIT', 49 ) 50) 51 52# Subset of the output from pushing to Gerrit. 53DEFAULT_OUTPUT = f''' 54remote: 55remote: https://{HELPER_GERRIT}-review.git.corp.google.com/c/{HELPER_PROJECT}/+/123456789 {_DNS} [NEW] 56remote: 57'''.strip() 58 59_LOG = logging.getLogger(__name__) 60 61 62def parse_args() -> argparse.Namespace: 63 """Creates an argument parser and parses arguments.""" 64 65 parser = argparse.ArgumentParser(description=__doc__) 66 parser.add_argument( 67 'requirements', 68 nargs='+', 69 help='Requirements to be added ("<gerrit-name>:<cl-number>").', 70 ) 71 parser.add_argument( 72 '--no-push', 73 dest='push', 74 action='store_false', 75 help=argparse.SUPPRESS, # This option is only for debugging. 76 ) 77 78 return parser.parse_args() 79 80 81def _run_command(*args, **kwargs): 82 kwargs.setdefault('capture_output', True) 83 _LOG.debug('%s', args) 84 _LOG.debug('%s', kwargs) 85 res = subprocess.run(*args, **kwargs) 86 _LOG.debug('%s', res.stdout) 87 _LOG.debug('%s', res.stderr) 88 res.check_returncode() 89 return res 90 91 92def check_status() -> bool: 93 res = subprocess.run(['git', 'status'], capture_output=True) 94 if res.returncode: 95 _LOG.error('repository not clean, commit to suppress this warning') 96 return False 97 return True 98 99 100def clone(requires_dir: Path) -> None: 101 _LOG.info('cloning helper repository into %s', requires_dir) 102 _run_command(['git', 'clone', HELPER_REPO, '.'], cwd=requires_dir) 103 104 105def create_commit(requires_dir: Path, requirements) -> None: 106 """Create a commit in the local tree with the given requirements.""" 107 change_id = str(uuid.uuid4()).replace('-', '00') 108 _LOG.debug('change_id %s', change_id) 109 110 reqs = [] 111 for req in requirements: 112 gerrit_name, number = req.split(':', 1) 113 reqs.append({'gerrit_name': gerrit_name, 'number': number}) 114 115 path = requires_dir / 'patches.json' 116 _LOG.debug('path %s', path) 117 with open(path, 'w') as outs: 118 json.dump(reqs, outs) 119 120 _run_command(['git', 'add', path], cwd=requires_dir) 121 122 commit_message = [ 123 f'{_DNS} {change_id[0:10]}\n\n', 124 '', 125 f'Change-Id: I{change_id}', 126 ] 127 for req in requirements: 128 commit_message.append(f'Requires: {req}') 129 130 _LOG.debug('message %s', commit_message) 131 _run_command( 132 ['git', 'commit', '-m', '\n'.join(commit_message)], 133 cwd=requires_dir, 134 ) 135 136 # Not strictly necessary, only used for logging. 137 _run_command(['git', 'show'], cwd=requires_dir) 138 139 140def push_commit(requires_dir: Path, push=True) -> str: 141 output = DEFAULT_OUTPUT 142 if push: 143 res = _run_command( 144 ['git', 'push', HELPER_REPO, '+HEAD:refs/for/main'], 145 cwd=requires_dir, 146 ) 147 output = res.stderr.decode() 148 149 _LOG.debug('output: %s', output) 150 regex = re.compile( 151 f'^\\s*remote:\\s*' 152 f'https://{HELPER_GERRIT}-review.(?:git.corp.google|googlesource).com/' 153 f'c/{HELPER_PROJECT}/\\+/(?P<num>\\d+)\\s+', 154 re.MULTILINE, 155 ) 156 _LOG.debug('regex %r', regex) 157 match = regex.search(output) 158 if not match: 159 raise ValueError(f"invalid output from 'git push': {output}") 160 change_num = match.group('num') 161 _LOG.info('created %s change %s', HELPER_PROJECT, change_num) 162 return f'{HELPER_GERRIT}:{change_num}' 163 164 165def amend_existing_change(change: str) -> None: 166 res = _run_command(['git', 'log', '-1', '--pretty=%B']) 167 original = res.stdout.rstrip().decode() 168 169 addition = f'Requires: {change}' 170 _LOG.info('adding "%s" to current commit message', addition) 171 message = '\n'.join((original, addition)) 172 _run_command(['git', 'commit', '--amend', '--message', message]) 173 174 175def run(requirements, push=True) -> int: 176 """Entry point for requires.""" 177 178 if not check_status(): 179 return -1 180 181 # Create directory for checking out helper repository. 182 with tempfile.TemporaryDirectory() as requires_dir_str: 183 requires_dir = Path(requires_dir_str) 184 # Clone into helper repository. 185 clone(requires_dir) 186 # Make commit with requirements from command line. 187 create_commit(requires_dir, requirements) 188 # Push that commit and save its number. 189 change = push_commit(requires_dir, push=push) 190 # Add dependency on newly pushed commit on current commit. 191 amend_existing_change(change) 192 193 return 0 194 195 196def main() -> int: 197 return run(**vars(parse_args())) 198 199 200if __name__ == '__main__': 201 try: 202 # If pw_cli is available, use it to initialize logs. 203 from pw_cli import log 204 205 log.install(logging.INFO) 206 except ImportError: 207 # If pw_cli isn't available, display log messages like a simple print. 208 logging.basicConfig(format='%(message)s', level=logging.INFO) 209 210 sys.exit(main()) 211