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