chromium/infra/config/scripts/milestones.py

#!/usr/bin/env vpython3
# Copyright 2020 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Script for updating the active milestones for the chromium project.

To activate a new chromium branch, run the following from the root of
the repo (where MM is the milestone number and BBBB is the branch
number):
```
scripts/chromium/milestones.py activate --milestone MM --branch BBBB
./main.star
```

To deactivate a chromium branch, run the following from the root of the
repo (where MM is the milestone number):
```
scripts/chromium/milestones.py deactivate --milestone MM
./main.star
```

Usage:
  milestones.py activate --milestone XX --branch YYYY
  milestones.py deactivate --milestone XX
"""

import argparse
import itertools
import json
import os
import re
import sys

INFRA_CONFIG_DIR = os.path.abspath(os.path.join(__file__, '..', '..'))

def parse_args(args=None, *, parser_type=None):
  parser_type = parser_type or argparse.ArgumentParser
  parser = parser_type(
      description='Update the active milestones for the chromium project')
  parser.set_defaults(func=None)
  parser.add_argument('--milestones-json',
                      help='Path to the milestones.json file',
                      default=os.path.join(INFRA_CONFIG_DIR, 'milestones.json'))

  subparsers = parser.add_subparsers()

  activate_parser = subparsers.add_parser(
      'activate', help='Add an additional active milestone')
  activate_parser.set_defaults(func=activate_cmd)
  activate_parser.add_argument(
      '--milestone',
      required=True,
      help=('The milestone identifier '
            '(e.g. the milestone number for standard release channel)'))
  activate_parser.add_argument(
      '--branch',
      required=True,
      help='The branch name, must correspond to a ref in refs/branch-heads')

  deactivate_parser = subparsers.add_parser(
      'deactivate', help='Remove an active milestone')
  deactivate_parser.set_defaults(func=deactivate_cmd)
  deactivate_parser.add_argument(
      '--milestone',
      required=True,
      help=('The milestone identifier '
            '(e.g. the milestone number for standard release channel)'))

  args = parser.parse_args(args)
  if args.func is None:
    parser.error('no sub-command specified')
  return args

class MilestonesException(Exception):
  pass

_NUMBER_RE  = re.compile('([0-9]+)')

def numeric_sort_key(s):
  # The capture group in the regex means that the numeric portions are returned,
  # odd indices will be the numeric portions of the string (the 0th or last
  # element will be empty if the string starts or ends with a number,
  # respectively)
  pieces = _NUMBER_RE.split(s)
  return [
      (int(x), x) if is_numeric else x
      for x, is_numeric
      in zip(pieces, itertools.cycle([False, True]))
  ]

def add_milestone(milestones, milestone, branch):
  if milestone in milestones:
    raise MilestonesException(
        f'there is already an active milestone with id {milestone!r}: '
        f'{milestones[milestone]}')

  milestones[milestone] = {
      'name': f'm{milestone}',
      'project': f'chromium-m{milestone}',
      'ref': f'refs/branch-heads/{branch}',
  }

  milestones = {
      k: milestones[k] for k in sorted(milestones, key=numeric_sort_key)
  }

  return json.dumps(milestones, indent=4) + '\n'

def activate_cmd(args):
  with open(args.milestones_json) as f:
    milestones = json.load(f)

  milestones = add_milestone(milestones, args.milestone, args.branch)

  with open(args.milestones_json, 'w') as f:
    f.write(milestones)

def remove_milestone(milestones, milestone):
  if milestone not in milestones:
    raise MilestonesException(
        f'{milestone!r} does not refer to an active milestone: '
        f'{list(milestones.keys())}')

  del milestones[milestone]

  milestones = {
      k: milestones[k] for k in sorted(milestones, key=numeric_sort_key)
  }

  return json.dumps(milestones, indent=4) + '\n'

def deactivate_cmd(args):
  with open(args.milestones_json) as f:
    milestones = json.load(f)

  milestones = remove_milestone(milestones, args.milestone)

  with open(args.milestones_json, 'w') as f:
    f.write(milestones)

def main():
  args = parse_args()
  try:
    args.func(args)
  except MilestonesException as e:
    print(str(e), file=sys.stderr)
    sys.exit(1)

if __name__ == '__main__':
  main()