@@ -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
------------
@@ -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)
@@ -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,
}
@@ -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()