[Concept,14/17] pickman: Add step command to create MR if none pending

Message ID 20251217022611.389379-15-sjg@u-boot.org
State New
Headers
Series pickman: Add a manager for cherry-picks |

Commit Message

Simon Glass Dec. 17, 2025, 2:26 a.m. UTC
  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(+)
  

Patch

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()