From patchwork Wed Dec 17 02:26:02 2025 Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: 7bit X-Patchwork-Submitter: Simon Glass X-Patchwork-Id: 940 Return-Path: X-Original-To: u-boot-concept@u-boot.org Delivered-To: u-boot-concept@u-boot.org DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=u-boot.org; s=default; t=1765938457; bh=MtdD/0j8lM/vML9b3m/z+fFhoQTU92LVle7jVijHUnE=; h=From:To:Date:In-Reply-To:References:CC:Subject:List-Id: List-Archive:List-Help:List-Owner:List-Post:List-Subscribe: List-Unsubscribe:From; b=FMCGBZDBTwLaXj6MtVgMMb6KkGVG0v03MWMxnU8NSjETi6ziIok0Q+lOoKC5HyqOt miYK3cU+96XbTK7Cysv6pzMXR/ouddN+ANAGxIPremGlNwHqsBcnKkLn64JoPLJMlO vrZrL5CttM2mJYeYj6t9qDtUjt3ui7HfDORnV9kIeHdT7mY+2K+I8Af61Y/HTeNbVB DqBKSJacYIcXE1rZ2icRvDx7G7vqdyHiCHmEalXYdyOhIhcoa7Exmkj6nFIMUWmI/X KqNQgV1SoUG9eITeVm+Ve8ZUHEAuKj8do09DcoCEkAcJmD1VoJA/C3GMjeW4+XZgzV aYwQJByqymyFw== Received: from localhost (localhost [127.0.0.1]) by mail.u-boot.org (Postfix) with ESMTP id D386568BAC for ; Tue, 16 Dec 2025 19:27:37 -0700 (MST) X-Virus-Scanned: Debian amavis at Received: from mail.u-boot.org ([127.0.0.1]) by localhost (mail.u-boot.org [127.0.0.1]) (amavis, port 10024) with ESMTP id DkcxJUtzL-LY for ; Tue, 16 Dec 2025 19:27:37 -0700 (MST) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=u-boot.org; s=default; t=1765938457; bh=MtdD/0j8lM/vML9b3m/z+fFhoQTU92LVle7jVijHUnE=; h=From:To:Date:In-Reply-To:References:CC:Subject:List-Id: List-Archive:List-Help:List-Owner:List-Post:List-Subscribe: List-Unsubscribe:From; b=FMCGBZDBTwLaXj6MtVgMMb6KkGVG0v03MWMxnU8NSjETi6ziIok0Q+lOoKC5HyqOt miYK3cU+96XbTK7Cysv6pzMXR/ouddN+ANAGxIPremGlNwHqsBcnKkLn64JoPLJMlO vrZrL5CttM2mJYeYj6t9qDtUjt3ui7HfDORnV9kIeHdT7mY+2K+I8Af61Y/HTeNbVB DqBKSJacYIcXE1rZ2icRvDx7G7vqdyHiCHmEalXYdyOhIhcoa7Exmkj6nFIMUWmI/X KqNQgV1SoUG9eITeVm+Ve8ZUHEAuKj8do09DcoCEkAcJmD1VoJA/C3GMjeW4+XZgzV aYwQJByqymyFw== Received: from mail.u-boot.org (localhost [127.0.0.1]) by mail.u-boot.org (Postfix) with ESMTP id 9679768AEE for ; Tue, 16 Dec 2025 19:27:37 -0700 (MST) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=u-boot.org; s=default; t=1765938455; bh=iMaw7sjFaogxg0mt8eQPdQ+tvMaBqAwNzBmH1iHixwc=; h=From:To:Cc:Subject:Date:In-Reply-To:References:From; b=IDGwaKLys7EGPj3bbqLcDhaBcj2Dsq0GgpAcSVfnhtFaz+8w0mq6IUbM4VsVWWKAB txDlanyG2KoRowsd29Ijfc54OPX7Rol/ZHye7hgHay9+F+NqYr3KAEcKhcSVbnGDnU XP+Z6/4cwSdrRQzRdWgwMP+YrQUR1Areupfst9Xn6fArAkiMWxaNtfH5QvPqNVN1O8 fUvyXehiOi6ZPod9cIqHviIIEll1EWjsOzxwC+RdmdX8vJt1GOapOItwlm9XfElvRB W2s6eLD/ihu8HsWOKOGLQ8flG1zyIPeJvoSuTCAmHtvvVxyocPPPUTiyF3J5TyUVC/ S1hEv1yRGSk5g== Received: from localhost (localhost [127.0.0.1]) by mail.u-boot.org (Postfix) with ESMTP id 68B3D68BBA; Tue, 16 Dec 2025 19:27:35 -0700 (MST) X-Virus-Scanned: Debian amavis at Received: from mail.u-boot.org ([127.0.0.1]) by localhost (mail.u-boot.org [127.0.0.1]) (amavis, port 10026) with ESMTP id Ftkhcii0ZVuR; Tue, 16 Dec 2025 19:27:35 -0700 (MST) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=u-boot.org; s=default; t=1765938449; bh=vuP78zIUe3dwyr3q52eDdjoQ4DH6lxZgfzkZDVJxuJo=; h=From:To:Cc:Subject:Date:In-Reply-To:References:From; b=aowAN0s9oUwCy58j3EIKQR12lV6U7/OQ7kuJ0qG9iLMzY0sYPHVhbpY9MG0lJ1RNt 9BTCeV2cdYhwhNMDSEq0wfoj+zD2Xz/LJnCfLFDZ3qzkSD/Z9DZe0lo1BdjAvlg2ta i41iAPU9fcDleipf4gYGgAdGlQkANBzD0AXqg9w9q/ycutMS9digRq4O7iCh4Eq8rM 4F1slAcJ1QauaA9HVwwWA1FwKrpD9xvtgLuQBIolRUtq+Y3VcutWuWarnvS1xY/kqA ECKxvD5U1ZgRZFmhig9bHmzjf8YG7mEqgoZxYaVeB3ZF0aa6WwODTZ2shTmRQ7Sdgn pUwmspRQya5tA== Received: from u-boot.org (unknown [73.34.74.121]) by mail.u-boot.org (Postfix) with ESMTPSA id 0CB4068ABD; Tue, 16 Dec 2025 19:27:28 -0700 (MST) From: Simon Glass To: U-Boot Concept Date: Tue, 16 Dec 2025 19:26:02 -0700 Message-ID: <20251217022611.389379-15-sjg@u-boot.org> X-Mailer: git-send-email 2.43.0 In-Reply-To: <20251217022611.389379-1-sjg@u-boot.org> References: <20251217022611.389379-1-sjg@u-boot.org> MIME-Version: 1.0 Message-ID-Hash: IIAK3S564QTKET2KUW73TYMJX275CFZK X-Message-ID-Hash: IIAK3S564QTKET2KUW73TYMJX275CFZK X-MailFrom: sjg@u-boot.org X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header CC: Heinrich Schuchardt , Simon Glass , "Claude Opus 4 . 5" X-Mailman-Version: 3.3.10 Precedence: list Subject: [Concept] [PATCH 14/17] pickman: Add step command to create MR if none pending List-Id: Discussion and patches related to U-Boot Concept Archived-At: List-Archive: List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: From: Simon Glass 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 Signed-off-by: Simon Glass --- 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()