• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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