• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1# Copyright 2024 The Pigweed Authors
2#
3# Licensed under the Apache License, Version 2.0 (the "License"); you may not
4# use this file except in compliance with the License. You may obtain a copy of
5# the License at
6#
7#     https://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12# License for the specific language governing permissions and limitations under
13# the License.
14"""Manages SEED documents in Pigweed."""
15
16import argparse
17import datetime
18import enum
19from dataclasses import dataclass
20from pathlib import Path
21import random
22import re
23import subprocess
24import sys
25import urllib.request
26from typing import Dict, Iterable, List, Optional, Tuple, Union
27
28import pw_cli.color
29import pw_cli.env
30from pw_cli.git_repo import GitRepo
31from pw_cli.tool_runner import BasicSubprocessRunner
32
33_NEW_SEED_TEMPLATE = '''.. _seed-{num:04d}:
34
35{title_underline}
36{formatted_title}
37{title_underline}
38.. seed::
39   :number: {num}
40   :name: {title}
41   :status: {status}
42   :proposal_date: {date}
43   :cl: {changelist}
44   :authors: {authors}
45   :facilitator: Unassigned
46
47-------
48Summary
49-------
50Write up your proposal here.
51'''
52
53
54class SeedStatus(enum.Enum):
55    """Possible states of a SEED proposal."""
56
57    DRAFT = 0
58    OPEN_FOR_COMMENTS = 1
59    LAST_CALL = 2
60    ACCEPTED = 3
61    REJECTED = 4
62    DEPRECATED = 5
63    SUPERSEDED = 6
64    ON_HOLD = 7
65    META = 8
66
67    def __str__(self) -> str:
68        if self is SeedStatus.DRAFT:
69            return 'Draft'
70        if self is SeedStatus.OPEN_FOR_COMMENTS:
71            return 'Open for Comments'
72        if self is SeedStatus.LAST_CALL:
73            return 'Last Call'
74        if self is SeedStatus.ACCEPTED:
75            return 'Accepted'
76        if self is SeedStatus.REJECTED:
77            return 'Rejected'
78        if self is SeedStatus.DEPRECATED:
79            return 'Deprecated'
80        if self is SeedStatus.SUPERSEDED:
81            return 'Superseded'
82        if self is SeedStatus.ON_HOLD:
83            return 'On Hold'
84        if self is SeedStatus.META:
85            return 'Meta'
86
87        return ''
88
89
90@dataclass
91class SeedMetadata:
92    number: int
93    title: str
94    authors: str
95    status: SeedStatus
96    changelist: Optional[int] = None
97    sources: Optional[List[str]] = None
98
99    def default_filename(self) -> str:
100        normalized_title = self.title.lower().replace(' ', '-')
101        normalized_title = re.sub(r'[^a-zA-Z0-9_-]', '', normalized_title)
102
103        return f'{self.number:04d}-{normalized_title}.rst'
104
105
106class SeedRegistry:
107    """
108    Represents a registry of SEEDs located somewhere in pigweed.git, which can
109    be read, modified, and written.
110
111    Currently, this is implemented as a basic text parser for the BUILD.gn file
112    in the seed/ directory; however, in the future it may be rewritten to use a
113    different backing data source.
114    """
115
116    class _State(enum.Enum):
117        OUTER = 0
118        SEED = 1
119        INDEX = 2
120        INDEX_SEEDS_LIST = 3
121
122    @classmethod
123    def parse(cls, registry_file: Path) -> 'SeedRegistry':
124        return cls(registry_file)
125
126    def __init__(self, seed_build_file: Path):
127        self._file = seed_build_file
128        self._lines = seed_build_file.read_text().split('\n')
129        self._seeds: Dict[int, Tuple[int, int]] = {}
130        self._next_seed_number = 101
131
132        seed_regex = re.compile(r'pw_seed\("(\d+)"\)')
133
134        state = SeedRegistry._State.OUTER
135
136        section_start_index = 0
137        current_seed_number = 0
138
139        # Run through the GN file, doing some basic parsing of its targets.
140        for i, line in enumerate(self._lines):
141            if state is SeedRegistry._State.OUTER:
142                seed_match = seed_regex.search(line)
143                if seed_match:
144                    # SEED definition target: extract the number.
145                    state = SeedRegistry._State.SEED
146
147                    section_start_index = i
148                    current_seed_number = int(seed_match.group(1))
149
150                    if current_seed_number >= self._next_seed_number:
151                        self._next_seed_number = current_seed_number + 1
152
153                if line == 'pw_seed_index("seeds") {':
154                    state = SeedRegistry._State.INDEX
155                    # Skip back past the comments preceding the SEED index
156                    # target. New SEEDs will be inserted here.
157                    insertion_index = i
158                    while insertion_index > 0 and self._lines[
159                        insertion_index - 1
160                    ].startswith('#'):
161                        insertion_index -= 1
162
163                    self._seed_insertion_index = insertion_index
164
165            if state is SeedRegistry._State.SEED:
166                if line == '}':
167                    self._seeds[current_seed_number] = (section_start_index, i)
168                    state = SeedRegistry._State.OUTER
169
170            if state is SeedRegistry._State.INDEX:
171                if line == '}':
172                    state = SeedRegistry._State.OUTER
173                if line == '  seeds = [':
174                    state = SeedRegistry._State.INDEX_SEEDS_LIST
175
176            if state is SeedRegistry._State.INDEX_SEEDS_LIST:
177                if line == '  ]':
178                    self._index_seeds_end = i
179                    state = SeedRegistry._State.INDEX
180
181    def file(self) -> Path:
182        """Returns the file which backs this registry."""
183        return self._file
184
185    def seed_count(self) -> int:
186        """Returns the number of SEEDs registered."""
187        return len(self._seeds)
188
189    def insert(self, seed: SeedMetadata) -> None:
190        """Adds a new seed to the registry."""
191
192        new_seed = [
193            f'pw_seed("{seed.number:04d}") {{',
194            f'  title = "{seed.title}"',
195            f'  author = "{seed.authors}"',
196            f'  status = "{seed.status}"',
197        ]
198
199        if seed.changelist is not None:
200            new_seed.append(f'  changelist = {seed.changelist}')
201
202        if seed.sources is not None:
203            if len(seed.sources) == 0:
204                new_seed.append('  sources = []')
205            elif len(seed.sources) == 1:
206                new_seed.append(f'  sources = [ "{seed.sources[0]}" ]')
207            else:
208                new_seed.append('  sources = [')
209                new_seed.extend(f'    "{source}",' for source in seed.sources)
210                new_seed.append('  ]')
211
212        new_seed += [
213            '}',
214            '',
215        ]
216        self._lines = (
217            self._lines[: self._seed_insertion_index]
218            + new_seed
219            + self._lines[self._seed_insertion_index : self._index_seeds_end]
220            + [f'    ":{seed.number:04d}",']
221            + self._lines[self._index_seeds_end :]
222        )
223
224        self._seed_insertion_index += len(new_seed)
225        self._index_seeds_end += len(new_seed)
226
227        if seed.number == self._next_seed_number:
228            self._next_seed_number += 1
229
230    def next_seed_number(self) -> int:
231        return self._next_seed_number
232
233    def write(self) -> None:
234        self._file.write_text('\n'.join(self._lines))
235
236
237_GERRIT_HOOK_URL = (
238    'https://gerrit-review.googlesource.com/tools/hooks/commit-msg'
239)
240
241
242# TODO: pwbug.dev/318746837 - Extract this to somewhere more general.
243def _install_gerrit_hook(git_root: Path) -> None:
244    hook_file = git_root / '.git' / 'hooks' / 'commit-msg'
245    urllib.request.urlretrieve(_GERRIT_HOOK_URL, hook_file)
246    hook_file.chmod(0o755)
247
248
249def _request_new_seed_metadata(
250    repo: GitRepo,
251    registry: SeedRegistry,
252    colors,
253) -> SeedMetadata:
254    if repo.has_uncommitted_changes():
255        print(
256            colors.red(
257                'You have uncommitted Git changes. '
258                'Please commit or stash before creating a SEED.'
259            )
260        )
261        sys.exit(1)
262
263    print(
264        colors.yellow(
265            'This command will create Git commits. '
266            'Make sure you are on a suitable branch.'
267        )
268    )
269    print(f'Current branch: {colors.cyan(repo.current_branch())}')
270    print('')
271
272    number = registry.next_seed_number()
273
274    try:
275        num = input(
276            f'SEED number (default={colors.bold_white(number)}): '
277        ).strip()
278
279        while True:
280            try:
281                if num:
282                    number = int(num)
283                break
284            except ValueError:
285                num = input('Invalid number entered. Try again: ').strip()
286
287        title = input('SEED title: ').strip()
288        while not title:
289            title = input(
290                'Title cannot be empty. Re-enter SEED title: '
291            ).strip()
292
293        authors = input('SEED authors: ').strip()
294        while not authors:
295            authors = input(
296                'Authors list cannot be empty. Re-enter SEED authors: '
297            ).strip()
298
299        print('The following SEED will be created.')
300        print('')
301        print(f'  Number: {colors.green(number)}')
302        print(f'  Title: {colors.green(title)}')
303        print(f'  Authors: {colors.green(authors)}')
304        print('')
305        print(
306            'This will create two commits on branch '
307            + colors.cyan(repo.current_branch())
308            + ' and push them to Gerrit.'
309        )
310
311        create = True
312        confirm = input(f'Proceed? [{colors.bold_white("Y")}/n] ').strip()
313        if confirm:
314            create = confirm == 'Y'
315
316    except KeyboardInterrupt:
317        print('\nReceived CTRL-C, exiting...')
318        sys.exit(0)
319
320    if not create:
321        sys.exit(0)
322
323    return SeedMetadata(
324        number=number,
325        title=title,
326        authors=authors,
327        status=SeedStatus.DRAFT,
328    )
329
330
331@dataclass
332class GerritChange:
333    id: str
334    number: int
335    title: str
336
337    def url(self) -> str:
338        return (
339            'https://pigweed-review.googlesource.com'
340            f'/c/pigweed/pigweed/+/{self.number}'
341        )
342
343
344def commit_and_push(
345    repo: GitRepo,
346    files: Iterable[Union[Path, str]],
347    commit_message: str,
348    change_id: Optional[str] = None,
349) -> GerritChange:
350    """Creates a commit with the given files and message and pushes to Gerrit.
351
352    Args:
353        change_id: Optional Gerrit change ID to use. If not specified, generates
354            a new one.
355    """
356    if change_id is not None:
357        commit_message = f'{commit_message}\n\nChange-Id: {change_id}'
358
359    subprocess.run(
360        ['git', 'add'] + list(files), capture_output=True, check=True
361    )
362    subprocess.run(
363        ['git', 'commit', '-m', commit_message], capture_output=True, check=True
364    )
365
366    if change_id is None:
367        # Parse the generated change ID from the commit if it wasn't
368        # explicitly set.
369        change_id = repo.commit_change_id()
370
371        if change_id is None:
372            # If the commit doesn't have a Change-Id, the Gerrit hook is not
373            # installed. Install it and try modifying the commit.
374            _install_gerrit_hook(repo.root())
375            subprocess.run(
376                ['git', 'commit', '--amend', '--no-edit'],
377                capture_output=True,
378                check=True,
379            )
380            change_id = repo.commit_change_id()
381            assert change_id is not None
382
383    process = subprocess.run(
384        [
385            'git',
386            'push',
387            'origin',
388            '+HEAD:refs/for/main',
389            '--no-verify',
390        ],
391        capture_output=True,
392        text=True,
393        check=True,
394    )
395
396    output = process.stderr
397
398    regex = re.compile(
399        '^\\s*remote:\\s*'
400        'https://pigweed-review.(?:git.corp.google|googlesource).com/'
401        'c/pigweed/pigweed/\\+/(?P<num>\\d+)\\s+',
402        re.MULTILINE,
403    )
404    match = regex.search(output)
405    if not match:
406        raise ValueError(f"invalid output from 'git push': {output}")
407    change_num = int(match.group('num'))
408
409    return GerritChange(change_id, change_num, commit_message.split('\n')[0])
410
411
412def _create_wip_seed_doc_change(
413    repo: GitRepo,
414    new_seed: SeedMetadata,
415) -> GerritChange:
416    """Commits and pushes a boilerplate CL doc to Gerrit.
417
418    Returns information about the CL.
419    """
420    env = pw_cli.env.pigweed_environment()
421    seed_rst_file = env.PW_ROOT / 'seed' / new_seed.default_filename()
422
423    formatted_title = f'{new_seed.number:04d}: {new_seed.title}'
424    title_underline = '=' * len(formatted_title)
425
426    seed_rst_file.write_text(
427        _NEW_SEED_TEMPLATE.format(
428            formatted_title=formatted_title,
429            title_underline=title_underline,
430            num=new_seed.number,
431            title=new_seed.title,
432            authors=new_seed.authors,
433            status=new_seed.status,
434            date=datetime.date.today().strftime('%Y-%m-%d'),
435            changelist=0,
436        )
437    )
438
439    temp_branch = f'wip-seed-{new_seed.number}-{random.randrange(0, 2**16):x}'
440    subprocess.run(
441        ['git', 'checkout', '-b', temp_branch], capture_output=True, check=True
442    )
443
444    commit_message = f'SEED-{new_seed.number:04d}: {new_seed.title}'
445
446    try:
447        cl = commit_and_push(repo, [seed_rst_file], commit_message)
448    except subprocess.CalledProcessError as err:
449        print(f'Command {err.cmd} failed; stderr:')
450        print(err.stderr)
451        sys.exit(1)
452    finally:
453        subprocess.run(
454            ['git', 'checkout', '-'], capture_output=True, check=True
455        )
456        subprocess.run(
457            ['git', 'branch', '-D', temp_branch],
458            capture_output=True,
459            check=True,
460        )
461
462    return cl
463
464
465def create_seed_number_claim_change(
466    repo: GitRepo,
467    new_seed: SeedMetadata,
468    registry: SeedRegistry,
469) -> GerritChange:
470    commit_message = f'SEED-{new_seed.number:04d}: Claim SEED number'
471    registry.insert(new_seed)
472    registry.write()
473    return commit_and_push(repo, [registry.file()], commit_message)
474
475
476def _create_seed_doc_change(
477    repo: GitRepo,
478    new_seed: SeedMetadata,
479    registry: SeedRegistry,
480    wip_change: GerritChange,
481) -> None:
482    env = pw_cli.env.pigweed_environment()
483    seed_rst_file = env.PW_ROOT / 'seed' / new_seed.default_filename()
484
485    formatted_title = f'{new_seed.number:04d}: {new_seed.title}'
486    title_underline = '=' * len(formatted_title)
487
488    seed_rst_file.write_text(
489        _NEW_SEED_TEMPLATE.format(
490            formatted_title=formatted_title,
491            title_underline=title_underline,
492            num=new_seed.number,
493            title=new_seed.title,
494            authors=new_seed.authors,
495            status=new_seed.status,
496            date=datetime.date.today().strftime('%Y-%m-%d'),
497            changelist=new_seed.changelist,
498        )
499    )
500
501    new_seed.sources = [seed_rst_file.relative_to(registry.file().parent)]
502    new_seed.changelist = None
503    registry.insert(new_seed)
504    registry.write()
505
506    commit_message = f'SEED-{new_seed.number:04d}: {new_seed.title}'
507    commit_and_push(
508        repo,
509        [registry.file(), seed_rst_file],
510        commit_message,
511        change_id=wip_change.id,
512    )
513
514
515def create_seed() -> int:
516    colors = pw_cli.color.colors()
517    env = pw_cli.env.pigweed_environment()
518
519    repo = GitRepo(env.PW_ROOT, BasicSubprocessRunner())
520
521    registry_path = env.PW_ROOT / 'seed' / 'BUILD.gn'
522
523    wip_registry = SeedRegistry.parse(registry_path)
524    registry = SeedRegistry.parse(registry_path)
525
526    seed = _request_new_seed_metadata(repo, wip_registry, colors)
527    seed_cl = _create_wip_seed_doc_change(repo, seed)
528
529    seed.changelist = seed_cl.number
530
531    number_cl = create_seed_number_claim_change(repo, seed, wip_registry)
532    _create_seed_doc_change(repo, seed, registry, seed_cl)
533
534    print()
535    print(f'Created two CLs for SEED-{seed.number:04d}:')
536    print()
537    print(f'-  {number_cl.title}')
538    print(f'   <{number_cl.url()}>')
539    print()
540    print(f'-  {seed_cl.title}')
541    print(f'   <{seed_cl.url()}>')
542
543    return 0
544
545
546def parse_args() -> argparse.Namespace:
547    parser = argparse.ArgumentParser(description=__doc__)
548    parser.set_defaults(func=lambda **_kwargs: parser.print_help())
549
550    subparsers = parser.add_subparsers(title='subcommands')
551
552    # No args for now, as this initially only runs in interactive mode.
553    create_parser = subparsers.add_parser('create', help='Creates a new SEED')
554    create_parser.set_defaults(func=create_seed)
555
556    return parser.parse_args()
557
558
559def main() -> int:
560    args = {**vars(parse_args())}
561    func = args['func']
562    del args['func']
563
564    exit_code = func(**args)
565    return 0 if exit_code is None else exit_code
566
567
568if __name__ == '__main__':
569    sys.exit(main())
570