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