Commit c04cc782 authored by Garrett Beaty's avatar Garrett Beaty Committed by Chromium LUCI CQ

Add script for automatically updating milestones.

Bug: 1165826
Change-Id: Ia76cb00dfbc488094563df41b25736f22ea128d6
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/2625392
Commit-Queue: Garrett Beaty <gbeaty@chromium.org>
Reviewed-by: default avatarMichael Moss <mmoss@chromium.org>
Cr-Commit-Position: refs/heads/master@{#843822}
parent c876b438
{
"86": {
"name": "m86",
"project": "chromium-m86",
"ref": "refs/branch-heads/4240"
},
"87": {
"name": "m87",
"project": "chromium-m87",
"ref": "refs/branch-heads/4280"
},
"88": {
"name": "m88",
"project": "chromium-m88",
"ref": "refs/branch-heads/4324"
}
}
......@@ -73,16 +73,6 @@ def _milestone_details(*, project, ref):
# The 3rd portion of the version number is the branch number for the associated
# milestone
ACTIVE_MILESTONES = {
"m86": _milestone_details(
project = "chromium-m86",
ref = "refs/branch-heads/4240",
),
"m87": _milestone_details(
project = "chromium-m87",
ref = "refs/branch-heads/4280",
),
"m88": _milestone_details(
project = "chromium-m88",
ref = "refs/branch-heads/4324",
),
m["name"]: _milestone_details(project = m["project"], ref = m["ref"])
for m in json.decode(io.read_file("./milestones.json")).values()
}
#!/usr/bin/env python3
# Copyright 2020 The Chromium Authors. All rights reserved.
# 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()
\ No newline at end of file
#!/usr/bin/env python3
# Copyright 2020 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Integration test for milestones.py"""
import json
import os
import subprocess
import tempfile
import textwrap
import unittest
INFRA_CONFIG_DIR = os.path.abspath(os.path.join(__file__, '..', '..', '..'))
MILESTONES_PY = os.path.join(INFRA_CONFIG_DIR, 'scripts', 'milestones.py')
class MilestonesIntgrationTest(unittest.TestCase):
def setUp(self):
self._temp_dir = tempfile.TemporaryDirectory()
self._milestones_json = os.path.join(self._temp_dir.name, 'milestones.json')
def tearDown(self):
self._temp_dir.cleanup()
def _execute_milestones_py(self, args):
return subprocess.run(
([MILESTONES_PY, '--milestones-json', self._milestones_json] +
(args or [])),
text=True, capture_output=True)
def test_activate_fails_when_missing_required_args(self):
result = self._execute_milestones_py(['activate'])
self.assertNotEqual(result.returncode, 0)
self.assertIn(
'the following arguments are required: --milestone, --branch',
result.stderr)
def test_activate_fails_when_milestone_already_active(self):
milestones = {
'99': {
'name': 'm99',
'project': 'chromium-m99',
'ref': 'refs/branch-heads/AAAA',
},
}
with open(self._milestones_json, 'w') as f:
json.dump(milestones, f, indent=4)
result = self._execute_milestones_py(
['activate', '--milestone', '99', '--branch', 'BBBB'])
self.assertNotEqual(result.returncode, 0)
self.assertIn(
"there is already an active milestone with id '99'",
result.stderr)
def test_activate_rewrites_milestones_json(self):
milestones = {
'99': {
'name': 'm99',
'project': 'chromium-m99',
'ref': 'refs/branch-heads/AAAA',
},
'101': {
'name': 'm101',
'project': 'chromium-m101',
'ref': 'refs/branch-heads/BBBB',
},
}
with open(self._milestones_json, 'w') as f:
json.dump(milestones, f, indent=4)
result = self._execute_milestones_py(
['activate', '--milestone', '100', '--branch', 'CCCC'])
self.assertEqual(
result.returncode, 0,
(f'subprocess failed\n***COMMAND***\n{result.args}\n'
f'***STDERR***\n{result.stderr}'))
with open(self._milestones_json) as f:
milestones = f.read()
self.assertEqual(milestones, textwrap.dedent("""\
{
"99": {
"name": "m99",
"project": "chromium-m99",
"ref": "refs/branch-heads/AAAA"
},
"100": {
"name": "m100",
"project": "chromium-m100",
"ref": "refs/branch-heads/CCCC"
},
"101": {
"name": "m101",
"project": "chromium-m101",
"ref": "refs/branch-heads/BBBB"
}
}
"""))
def test_deactivate_fails_when_missing_required_args(self):
result = self._execute_milestones_py(['deactivate'])
self.assertNotEqual(result.returncode, 0)
self.assertIn(
'the following arguments are required: --milestone',
result.stderr)
def test_deactivate_fails_when_milestone_not_active(self):
milestones = {}
with open(self._milestones_json, 'w') as f:
json.dump(milestones, f, indent=4)
result = self._execute_milestones_py(['deactivate', '--milestone', '99'])
self.assertNotEqual(result.returncode, 0)
self.assertIn(
"'99' does not refer to an active milestone", result.stderr)
def test_deactivate_rewrites_milestones_json(self):
milestones = {
'99': {
'name': 'm99',
'project': 'chromium-m99',
'ref': 'refs/branch-heads/AAAA',
},
'101': {
'name': 'm101',
'project': 'chromium-m101',
'ref': 'refs/branch-heads/BBBB',
},
'100': {
'name': 'm100',
'project': 'chromium-m100',
'ref': 'refs/branch-heads/CCCC'
},
}
with open(self._milestones_json, 'w') as f:
json.dump(milestones, f, indent=4)
result = self._execute_milestones_py(['deactivate', '--milestone', '99'])
self.assertEqual(
result.returncode, 0,
(f'subprocess failed\n***COMMAND***\n{result.args}\n'
f'***STDERR***\n{result.stderr}'))
with open(self._milestones_json) as f:
milestones = f.read()
self.assertEqual(milestones, textwrap.dedent("""\
{
"100": {
"name": "m100",
"project": "chromium-m100",
"ref": "refs/branch-heads/CCCC"
},
"101": {
"name": "m101",
"project": "chromium-m101",
"ref": "refs/branch-heads/BBBB"
}
}
"""))
if __name__ == '__main__':
unittest.main()
\ No newline at end of file
#!/usr/bin/env python3
# Copyright 2020 The Chromium Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Unit test for milestones.py"""
import argparse
import os
import sys
import tempfile
import textwrap
import unittest
INFRA_CONFIG_DIR = os.path.abspath(os.path.join(__file__, '..', '..', '..'))
sys.path.append(os.path.join(INFRA_CONFIG_DIR, 'scripts'))
import milestones
class ParseError(Exception):
pass
class ArgumentParser(argparse.ArgumentParser):
"""Test version of ArgumentParser
This behaves the same as argparse.ArgumentParser except that the error
method raises an instance of ParseError rather than printing output
and exiting. This simplifies testing for error conditions and puts the
actual error information in the traceback for unexpectedly failing
tests.
"""
def error(self, message):
raise ParseError(message)
class MilestonesUnitTest(unittest.TestCase):
def test_parse_args_fails_without_subcommand(self):
with self.assertRaises(ParseError) as caught:
milestones.parse_args([], parser_type=ArgumentParser)
self.assertEqual(str(caught.exception), 'no sub-command specified')
def test_activate_parse_args_fails_when_missing_required_args(self):
with self.assertRaises(ParseError) as caught:
milestones.parse_args(['activate'], parser_type=ArgumentParser)
self.assertEqual(
str(caught.exception),
'the following arguments are required: --milestone, --branch')
def test_activate_parse_args(self):
args = milestones.parse_args(
['activate', '--milestone', 'MM', '--branch', 'BBBB'],
parser_type=ArgumentParser)
self.assertEqual(args.milestone, 'MM')
self.assertEqual(args.branch, 'BBBB')
def test_numeric_sort_key(self):
self.assertEqual(
sorted(['b10', 'b010', 'b9', 'a10', 'a1', 'a9'],
key=milestones.numeric_sort_key),
['a1', 'a9', 'a10', 'b9', 'b010', 'b10'])
def test_add_milestone_fails_when_milestone_already_active(self):
current_milestones = {
'99': {
'name': 'm99',
'project': 'chromium-m99',
'ref': 'refs/branch-heads/AAAA',
},
}
with self.assertRaises(milestones.MilestonesException) as caught:
milestones.add_milestone(
current_milestones, milestone='99', branch='BBBB')
self.assertIn(
"there is already an active milestone with id '99'",
str(caught.exception))
def test_add_milestone(self):
current_milestones = {
'99': {
'name': 'm99',
'project': 'chromium-m99',
'ref': 'refs/branch-heads/AAAA',
},
'101': {
'name': 'm101',
'project': 'chromium-m101',
'ref': 'refs/branch-heads/BBBB',
},
}
output = milestones.add_milestone(
current_milestones, milestone='100', branch='CCCC')
self.assertEqual(output, textwrap.dedent("""\
{
"99": {
"name": "m99",
"project": "chromium-m99",
"ref": "refs/branch-heads/AAAA"
},
"100": {
"name": "m100",
"project": "chromium-m100",
"ref": "refs/branch-heads/CCCC"
},
"101": {
"name": "m101",
"project": "chromium-m101",
"ref": "refs/branch-heads/BBBB"
}
}
"""))
def test_remove_milestone_fails_when_milestone_not_active(self):
current_milestones = {}
with self.assertRaises(milestones.MilestonesException) as caught:
milestones.remove_milestone(current_milestones, '99')
self.assertIn(
"'99' does not refer to an active milestone", str(caught.exception))
def test_remove_milestone(self):
current_milestones = {
'99': {
'name': 'm99',
'project': 'chromium-m99',
'ref': 'refs/branch-heads/AAAA',
},
'101': {
'name': 'm101',
'project': 'chromium-m101',
'ref': 'refs/branch-heads/BBBB',
},
'100': {
'name': 'm100',
'project': 'chromium-m100',
'ref': 'refs/branch-heads/CCCC'
},
}
output = milestones.remove_milestone(current_milestones, milestone='99')
self.assertEqual(output, textwrap.dedent("""\
{
"100": {
"name": "m100",
"project": "chromium-m100",
"ref": "refs/branch-heads/CCCC"
},
"101": {
"name": "m101",
"project": "chromium-m101",
"ref": "refs/branch-heads/BBBB"
}
}
"""))
if __name__ == '__main__':
unittest.main()
\ No newline at end of file
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment