From: Simon Glass <simon.glass@canonical.com> Add do_step() command that checks for open pickman MRs first. If none exist, it runs apply with --push to create a new one. This enables automated cherry-picking workflows where only one MR is active at a time. Co-developed-by: Claude Opus 4.5 <noreply@anthropic.com> Signed-off-by: Simon Glass <simon.glass@canonical.com> --- tools/pickman/README.rst | 26 ++++++++++ tools/pickman/__main__.py | 18 +++++++ tools/pickman/control.py | 72 +++++++++++++++++++++++++++ tools/pickman/ftest.py | 100 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 216 insertions(+) diff --git a/tools/pickman/README.rst b/tools/pickman/README.rst index f17ab734580..0d2c55e762a 100644 --- a/tools/pickman/README.rst +++ b/tools/pickman/README.rst @@ -92,6 +92,32 @@ Options for the review command: - ``-r, --remote``: Git remote (default: ci) +To automatically create an MR if none is pending:: + + ./tools/pickman/pickman step us/next + +This checks for open pickman MRs (those with ``[pickman]`` in the title) and if +none exist, runs ``apply`` with ``--push`` to create a new one. This is useful +for automated workflows where only one MR should be active at a time. + +Options for the step command: + +- ``-r, --remote``: Git remote for push (default: ci) +- ``-t, --target``: Target branch for MR (default: master) + +To run step continuously in a polling loop:: + + ./tools/pickman/pickman poll us/next + +This runs the ``step`` command repeatedly with a configurable interval, +creating new MRs as previous ones are merged. Press Ctrl+C to stop. + +Options for the poll command: + +- ``-i, --interval``: Interval between steps in seconds (default: 300) +- ``-r, --remote``: Git remote for push (default: ci) +- ``-t, --target``: Target branch for MR (default: master) + Requirements ------------ diff --git a/tools/pickman/__main__.py b/tools/pickman/__main__.py index 17f7d72a619..3d311264f06 100755 --- a/tools/pickman/__main__.py +++ b/tools/pickman/__main__.py @@ -62,6 +62,24 @@ def parse_args(argv): review_cmd.add_argument('-r', '--remote', default='ci', help='Git remote (default: ci)') + step_cmd = subparsers.add_parser('step', + help='Create MR if none pending') + step_cmd.add_argument('source', help='Source branch name') + step_cmd.add_argument('-r', '--remote', default='ci', + help='Git remote (default: ci)') + step_cmd.add_argument('-t', '--target', default='master', + help='Target branch for MR (default: master)') + + poll_cmd = subparsers.add_parser('poll', + help='Run step repeatedly until stopped') + poll_cmd.add_argument('source', help='Source branch name') + poll_cmd.add_argument('-i', '--interval', type=int, default=300, + help='Interval between steps in seconds (default: 300)') + poll_cmd.add_argument('-r', '--remote', default='ci', + help='Git remote (default: ci)') + poll_cmd.add_argument('-t', '--target', default='master', + help='Target branch for MR (default: master)') + subparsers.add_parser('test', help='Run tests') return parser.parse_args(argv) diff --git a/tools/pickman/control.py b/tools/pickman/control.py index 1216b27bc9b..6efb20df1d5 100644 --- a/tools/pickman/control.py +++ b/tools/pickman/control.py @@ -513,6 +513,76 @@ def do_review(args, dbs): # pylint: disable=unused-argument return 0 +def do_step(args, dbs): + """Create an MR if none is pending + + Checks for open pickman MRs and if none exist, runs apply with push + to create a new one. + + Args: + args (Namespace): Parsed arguments with 'source', 'remote', 'target' + dbs (Database): Database instance + + Returns: + int: 0 on success, 1 on failure + """ + remote = args.remote + + # Check for open pickman MRs + mrs = gitlab_api.get_open_pickman_mrs(remote) + if mrs is None: + return 1 + + if mrs: + tout.info(f'Found {len(mrs)} open pickman MR(s):') + for merge_req in mrs: + tout.info(f" !{merge_req['iid']}: {merge_req['title']}") + tout.info('') + tout.info('Not creating new MR while others are pending') + return 0 + + # No pending MRs, run apply with push + tout.info('No pending pickman MRs, creating new one...') + args.push = True + args.branch = None # Let do_apply generate branch name + return do_apply(args, dbs) + + +def do_poll(args, dbs): + """Run step repeatedly until stopped + + Runs the step command in a loop with a configurable interval. Useful for + automated workflows that continuously process cherry-picks. + + Args: + args (Namespace): Parsed arguments with 'source', 'interval', 'remote', + 'target' + dbs (Database): Database instance + + Returns: + int: 0 on success (never returns unless interrupted) + """ + import time + + interval = args.interval + tout.info(f'Polling every {interval} seconds (Ctrl+C to stop)...') + tout.info('') + + while True: + try: + ret = do_step(args, dbs) + if ret != 0: + tout.warning(f'Step returned {ret}, continuing anyway...') + tout.info('') + tout.info(f'Sleeping {interval} seconds...') + time.sleep(interval) + tout.info('') + except KeyboardInterrupt: + tout.info('') + tout.info('Polling stopped by user') + return 0 + + def do_test(args, dbs): # pylint: disable=unused-argument """Run tests for this module. @@ -539,7 +609,9 @@ COMMANDS = { 'compare': do_compare, 'list-sources': do_list_sources, 'next-set': do_next_set, + 'poll': do_poll, 'review': do_review, + 'step': do_step, 'test': do_test, } diff --git a/tools/pickman/ftest.py b/tools/pickman/ftest.py index 0e722bff48c..252fb79d2be 100644 --- a/tools/pickman/ftest.py +++ b/tools/pickman/ftest.py @@ -1105,6 +1105,56 @@ class TestParseApplyWithPush(unittest.TestCase): self.assertEqual(args.target, 'main') +class TestParseStep(unittest.TestCase): + """Tests for step command argument parsing.""" + + def test_parse_step_defaults(self): + """Test parsing step command with defaults.""" + args = pickman.parse_args(['step', 'us/next']) + self.assertEqual(args.cmd, 'step') + self.assertEqual(args.source, 'us/next') + self.assertEqual(args.remote, 'ci') + self.assertEqual(args.target, 'master') + + def test_parse_step_with_options(self): + """Test parsing step command with all options.""" + args = pickman.parse_args(['step', 'us/next', '-r', 'origin', + '-t', 'main']) + self.assertEqual(args.cmd, 'step') + self.assertEqual(args.source, 'us/next') + self.assertEqual(args.remote, 'origin') + self.assertEqual(args.target, 'main') + + +class TestStep(unittest.TestCase): + """Tests for step command.""" + + def test_step_with_pending_mr(self): + """Test step does nothing when MR is pending.""" + mock_mr = { + 'iid': 123, + 'title': '[pickman] Test MR', + 'web_url': 'https://gitlab.com/mr/123', + } + with mock.patch.object(gitlab_api, 'get_open_pickman_mrs', + return_value=[mock_mr]): + args = argparse.Namespace(cmd='step', source='us/next', + remote='ci', target='master') + ret = control.do_step(args, None) + + self.assertEqual(ret, 0) + + def test_step_gitlab_error(self): + """Test step when GitLab API returns error.""" + with mock.patch.object(gitlab_api, 'get_open_pickman_mrs', + return_value=None): + args = argparse.Namespace(cmd='step', source='us/next', + remote='ci', target='master') + ret = control.do_step(args, None) + + self.assertEqual(ret, 1) + + class TestParseReview(unittest.TestCase): """Tests for review command argument parsing.""" @@ -1143,5 +1193,55 @@ class TestReview(unittest.TestCase): self.assertEqual(ret, 1) +class TestParsePoll(unittest.TestCase): + """Tests for poll command argument parsing.""" + + def test_parse_poll_defaults(self): + """Test parsing poll command with defaults.""" + args = pickman.parse_args(['poll', 'us/next']) + self.assertEqual(args.cmd, 'poll') + self.assertEqual(args.source, 'us/next') + self.assertEqual(args.interval, 300) + self.assertEqual(args.remote, 'ci') + self.assertEqual(args.target, 'master') + + def test_parse_poll_with_options(self): + """Test parsing poll command with all options.""" + args = pickman.parse_args([ + 'poll', 'us/next', + '-i', '60', '-r', 'origin', '-t', 'main' + ]) + self.assertEqual(args.cmd, 'poll') + self.assertEqual(args.source, 'us/next') + self.assertEqual(args.interval, 60) + self.assertEqual(args.remote, 'origin') + self.assertEqual(args.target, 'main') + + +class TestPoll(unittest.TestCase): + """Tests for poll command.""" + + def test_poll_stops_on_keyboard_interrupt(self): + """Test poll stops gracefully on KeyboardInterrupt.""" + call_count = [0] + + def mock_step(args, dbs): + call_count[0] += 1 + if call_count[0] >= 2: + raise KeyboardInterrupt + return 0 + + with mock.patch.object(control, 'do_step', mock_step): + with mock.patch('time.sleep'): + args = argparse.Namespace( + cmd='poll', source='us/next', interval=1, + remote='ci', target='master' + ) + ret = control.do_poll(args, None) + + self.assertEqual(ret, 0) + self.assertEqual(call_count[0], 2) + + if __name__ == '__main__': unittest.main() -- 2.43.0