[PATCH 00/24] pickman: Refine the feature set
From: Simon Glass <simon.glass@canonical.com> This series adds a few more features and tweaks to make pickman work better in practice. The main issue is that the branch logic is not correct, so it selects the wrong branch once one has already been applied. Other improvements in this series: - stop polling if an error occurs - allow using a config file for the gitlab credentials (so we can set up a 'pickman' user), along with a command to check gitlab access - add the Claude log to the merge request, when it responds to comments - maintain comments in a database so we know what was addressed - add a command to figure out how many merges are pending and to show the next 10 - measure test coverage, improve it (not 100%) and add to CI Simon Glass (24): pickman: Add terminal.capture() to tests for silent output pickman: Move imports to top of control.py pickman: Fix pylint warnings pickman: Complete database.py coverage pickman: Add test coverage support pickman: Add tests to improve control.py coverage pickman: Improve testing with write_history() pickman: Extract prepare_apply() from do_apply() pickman: Extract execute_apply() from do_apply() pickman: Use named tuples for MR data pickman: Fix ancestor check in process_merged_mrs pickman: Fix merge selection to follow first-parent chain pickman: Add next-merges command pickman: Fix parse_mr_description to ignore short numbers pickman: Add database tracking for comment processing pickman: Add a way to update a gitlab merge-request pickman: Improve review handling with comment tracking pickman: Add count-merges command pickman: Stop poll on errors pickman: Add config-file support for GitLab token pickman: Add check-gitlab command pickman: Skip push pipeline for MR branches gitlab-ci: Run lab tests automatically for MR pipelines gitlab-ci: Add pickman tests to CI .gitlab-ci.yml | 8 +- tools/pickman/README.rst | 63 +- tools/pickman/__main__.py | 83 +- tools/pickman/agent.py | 19 +- tools/pickman/control.py | 467 ++++++++--- tools/pickman/database.py | 63 +- tools/pickman/ftest.py | 1320 ++++++++++++++++++++++++++++++- tools/pickman/gitlab_api.py | 218 ++++- tools/pickman/requirements.txt | 4 + tools/u_boot_pylib/test_util.py | 8 +- 10 files changed, 2088 insertions(+), 165 deletions(-) create mode 100644 tools/pickman/requirements.txt -- 2.43.0 base-commit: e7f94bcbcb083a5c2f667193069c0bf89dbbd22f branch: cherry2
From: Simon Glass <simon.glass@canonical.com> Add terminal.capture() context manager to tests that may produce terminal output, ensuring tests run silently unless explicitly checking output content. Fix a few pyline warnings while here. Signed-off-by: Simon Glass <simon.glass@canonical.com> Co-developed-by: Claude Opus 4.5 <noreply@anthropic.com> --- tools/pickman/ftest.py | 35 +++++++++++++++++++++++------------ 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/tools/pickman/ftest.py b/tools/pickman/ftest.py index e5ed187bdf6..8e3b91df750 100644 --- a/tools/pickman/ftest.py +++ b/tools/pickman/ftest.py @@ -51,7 +51,8 @@ class TestRunGit(unittest.TestCase): result = command.CommandResult(stdout=' output with spaces \n') command.TEST_RESULT = result try: - out = control.run_git(['status']) + with terminal.capture(): + out = control.run_git(['status']) self.assertEqual(out, 'output with spaces') finally: command.TEST_RESULT = None @@ -73,7 +74,8 @@ class TestCompareBranches(unittest.TestCase): command.TEST_RESULT = handle_command try: - count, commit = control.compare_branches('master', 'source') + with terminal.capture(): + count, commit = control.compare_branches('master', 'source') self.assertEqual(count, 42) self.assertEqual(commit.hash, 'abc123def456789') @@ -96,7 +98,8 @@ class TestCompareBranches(unittest.TestCase): command.TEST_RESULT = handle_command try: - count, commit = control.compare_branches('branch1', 'branch2') + with terminal.capture(): + count, commit = control.compare_branches('branch1', 'branch2') self.assertEqual(count, 0) self.assertEqual(commit.short_hash, 'def456a') @@ -1071,13 +1074,15 @@ class TestCheckAvailable(unittest.TestCase): def test_check_available_false(self): """Test check_available returns False when gitlab not installed.""" with mock.patch.object(gitlab_api, 'AVAILABLE', False): - result = gitlab_api.check_available() + with terminal.capture(): + result = gitlab_api.check_available() self.assertFalse(result) def test_check_available_true(self): """Test check_available returns True when gitlab is installed.""" with mock.patch.object(gitlab_api, 'AVAILABLE', True): - result = gitlab_api.check_available() + with terminal.capture(): + result = gitlab_api.check_available() self.assertTrue(result) @@ -1190,7 +1195,8 @@ class TestStep(unittest.TestCase): return_value=[mock_mr]): args = argparse.Namespace(cmd='step', source='us/next', remote='ci', target='master') - ret = control.do_step(args, None) + with terminal.capture(): + ret = control.do_step(args, None) self.assertEqual(ret, 0) @@ -1200,7 +1206,8 @@ class TestStep(unittest.TestCase): return_value=None): args = argparse.Namespace(cmd='step', source='us/next', remote='ci', target='master') - ret = control.do_step(args, None) + with terminal.capture(): + ret = control.do_step(args, None) self.assertEqual(ret, 1) @@ -1212,7 +1219,8 @@ class TestStep(unittest.TestCase): return_value=None): args = argparse.Namespace(cmd='step', source='us/next', remote='ci', target='master') - ret = control.do_step(args, None) + with terminal.capture(): + ret = control.do_step(args, None) self.assertEqual(ret, 1) @@ -1241,7 +1249,8 @@ class TestReview(unittest.TestCase): with mock.patch.object(gitlab_api, 'get_open_pickman_mrs', return_value=[]): args = argparse.Namespace(cmd='review', remote='ci') - ret = control.do_review(args, None) + with terminal.capture(): + ret = control.do_review(args, None) self.assertEqual(ret, 0) @@ -1250,7 +1259,8 @@ class TestReview(unittest.TestCase): with mock.patch.object(gitlab_api, 'get_open_pickman_mrs', return_value=None): args = argparse.Namespace(cmd='review', remote='ci') - ret = control.do_review(args, None) + with terminal.capture(): + ret = control.do_review(args, None) self.assertEqual(ret, 1) @@ -1287,7 +1297,7 @@ class TestPoll(unittest.TestCase): """Test poll stops gracefully on KeyboardInterrupt.""" call_count = [0] - def mock_step(args, dbs): + def mock_step(_args, _dbs): call_count[0] += 1 if call_count[0] >= 2: raise KeyboardInterrupt @@ -1299,7 +1309,8 @@ class TestPoll(unittest.TestCase): cmd='poll', source='us/next', interval=1, remote='ci', target='master' ) - ret = control.do_poll(args, None) + with terminal.capture(): + ret = control.do_poll(args, None) self.assertEqual(ret, 0) self.assertEqual(call_count[0], 2) -- 2.43.0
From: Simon Glass <simon.glass@canonical.com> Move datetime, re, and time imports from inside functions to the top of the file, following Python style guidelines. Also fix a few pylint warnings while here. Co-developed-by: Claude Opus 4.5 <noreply@anthropic.com> Signed-off-by: Simon Glass <simon.glass@canonical.com> --- tools/pickman/control.py | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/tools/pickman/control.py b/tools/pickman/control.py index 96b014dfeca..0353bae9350 100644 --- a/tools/pickman/control.py +++ b/tools/pickman/control.py @@ -6,8 +6,11 @@ """Control module for pickman - handles the main logic.""" from collections import namedtuple +from datetime import date import os +import re import sys +import time import unittest # Allow 'from pickman import xxx' to work via symlink @@ -61,9 +64,9 @@ def compare_branches(master, source): # Get details about the merge-base commit info = run_git(['log', '-1', '--format=%H%n%h%n%s%n%ci', base]) - full_hash, short_hash, subject, date = info.split('\n') + full_hash, short_hash, subject, commit_date = info.split('\n') - return count, Commit(full_hash, short_hash, subject, date) + return count, Commit(full_hash, short_hash, subject, commit_date) def do_add_source(args, dbs): @@ -242,8 +245,6 @@ def format_history_summary(source, commits, branch_name): Returns: str: Formatted summary text """ - from datetime import date - commit_list = '\n'.join( f'- {c.short_hash} {c.subject}' for c in commits @@ -266,9 +267,6 @@ def write_history(source, commits, branch_name, conversation_log): branch_name (str): Name of the cherry-pick branch conversation_log (str): The agent's conversation output """ - import os - import re - summary = format_history_summary(source, commits, branch_name) entry = f"""{summary} @@ -522,8 +520,6 @@ def parse_mr_description(description): Returns: tuple: (source_branch, last_commit_hash) or (None, None) if not parseable """ - import re - # Extract source branch from "## date: source_branch" line source_match = re.search(r'^## [^:]+: (.+)$', description, re.MULTILINE) if not source_match: @@ -664,8 +660,6 @@ def do_poll(args, dbs): 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('') -- 2.43.0
From: Simon Glass <simon.glass@canonical.com> - Remove unused commit_hashes variable in agent.py - Add pylint disable for too-many-public-methods in database.py - Add pylint disable for too-many-arguments in database.py functions - Add pylint disable for too-many-branches in control.py do_apply() - Add pylint disable for too-many-lines in ftest.py - Prefix unused mock arguments with underscore in ftest.py This brings the pylint score to 10.00/10. Co-developed-by: Claude Opus 4.5 <noreply@anthropic.com> Signed-off-by: Simon Glass <simon.glass@canonical.com> --- tools/pickman/agent.py | 1 - tools/pickman/control.py | 2 +- tools/pickman/database.py | 4 +++- tools/pickman/ftest.py | 1 + 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/tools/pickman/agent.py b/tools/pickman/agent.py index 76cf8f98ec3..176cf9773a0 100644 --- a/tools/pickman/agent.py +++ b/tools/pickman/agent.py @@ -60,7 +60,6 @@ async def run(commits, source, branch_name, repo_path=None): f' - {short_hash}: {subject}' for _, short_hash, subject in commits ) - commit_hashes = ' '.join(hash for hash, _, _ in commits) prompt = f"""Cherry-pick the following commits from {source} branch: diff --git a/tools/pickman/control.py b/tools/pickman/control.py index 0353bae9350..474315f7db0 100644 --- a/tools/pickman/control.py +++ b/tools/pickman/control.py @@ -299,7 +299,7 @@ def write_history(source, commits, branch_name, conversation_log): tout.info(f'Updated {HISTORY_FILE}') -def do_apply(args, dbs): # pylint: disable=too-many-locals +def do_apply(args, dbs): # pylint: disable=too-many-locals,too-many-branches """Apply the next set of commits using Claude agent Args: diff --git a/tools/pickman/database.py b/tools/pickman/database.py index 118ac5536fa..c8ed8a6df09 100644 --- a/tools/pickman/database.py +++ b/tools/pickman/database.py @@ -24,7 +24,7 @@ LATEST = 2 DB_FNAME = '.pickman.db' -class Database: +class Database: # pylint: disable=too-many-public-methods """Database of cherry-pick state used by pickman""" # dict of databases: @@ -248,6 +248,7 @@ class Database: # commit functions + # pylint: disable-next=too-many-arguments def commit_add(self, chash, source_id, subject, author, status='pending', mergereq_id=None): """Add a commit to the database @@ -348,6 +349,7 @@ class Database: # mergereq functions + # pylint: disable-next=too-many-arguments def mergereq_add(self, source_id, branch_name, mr_id, status, url, created_at): """Add a merge request to the database diff --git a/tools/pickman/ftest.py b/tools/pickman/ftest.py index 8e3b91df750..b479f2e0ecd 100644 --- a/tools/pickman/ftest.py +++ b/tools/pickman/ftest.py @@ -3,6 +3,7 @@ # Copyright 2025 Canonical Ltd. # Written by Simon Glass <simon.glass@canonical.com> # +# pylint: disable=too-many-lines """Tests for pickman.""" import argparse -- 2.43.0
From: Simon Glass <simon.glass@canonical.com> Add tests for the remaining uncovered database.py code paths: - Duplicate database creation error - Already open error when opening twice - Already closed error when closing twice - rollback() method This brings database.py to 100% test coverage. Co-developed-by: Claude Opus 4.5 <noreply@anthropic.com> Signed-off-by: Simon Glass <simon.glass@canonical.com> --- tools/pickman/ftest.py | 47 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tools/pickman/ftest.py b/tools/pickman/ftest.py index b479f2e0ecd..cc4a5727ac4 100644 --- a/tools/pickman/ftest.py +++ b/tools/pickman/ftest.py @@ -314,6 +314,53 @@ class TestDatabase(unittest.TestCase): self.assertIs(dbs1, dbs2) dbs1.close() + def test_duplicate_database_error(self): + """Test creating duplicate database raises error.""" + with terminal.capture(): + dbs = database.Database(self.db_path) + dbs.start() + with self.assertRaises(ValueError) as ctx: + database.Database(self.db_path) + self.assertIn('already a database', str(ctx.exception)) + dbs.close() + + def test_open_already_open_error(self): + """Test opening already open database raises error.""" + with terminal.capture(): + dbs = database.Database(self.db_path) + dbs.start() + with self.assertRaises(ValueError) as ctx: + dbs.open_it() + self.assertIn('Already open', str(ctx.exception)) + dbs.close() + + def test_close_already_closed_error(self): + """Test closing already closed database raises error.""" + with terminal.capture(): + dbs = database.Database(self.db_path) + dbs.start() + dbs.close() + with self.assertRaises(ValueError) as ctx: + dbs.close() + self.assertIn('Already closed', str(ctx.exception)) + + def test_rollback(self): + """Test rollback discards uncommitted changes.""" + with terminal.capture(): + dbs = database.Database(self.db_path) + dbs.start() + dbs.source_set('us/next', 'abc123') + dbs.commit() + + # Make a change but don't commit + dbs.source_set('us/next', 'def456') + # Rollback should discard the change + dbs.rollback() + + result = dbs.source_get('us/next') + self.assertEqual(result, 'abc123') + dbs.close() + def test_source_get_all(self): """Test getting all sources.""" with terminal.capture(): -- 2.43.0
From: Simon Glass <simon.glass@canonical.com> Add support for running pickman tests with code coverage checking, similar to binman. Use test_util.run_test_suites() for running tests and test_util.run_test_coverage() for coverage checking. Options added to the 'test' command: -P: Number of processes for parallel test execution -T: Run with coverage checking -v: Verbosity level (0-4) The coverage check allows failures for modules that require external services (agent.py, gitlab_api.py, control.py, database.py) since these cannot easily achieve 100% coverage in unit tests. Also update test_util.py to recognize 'pickman' as using the 'test' subcommand like binman and patman. Co-developed-by: Claude Opus 4.5 <noreply@anthropic.com> Signed-off-by: Simon Glass <simon.glass@canonical.com> --- tools/pickman/__main__.py | 67 ++++++++++++++++++++++++++++++++- tools/u_boot_pylib/test_util.py | 8 ++-- 2 files changed, 70 insertions(+), 5 deletions(-) diff --git a/tools/pickman/__main__.py b/tools/pickman/__main__.py index 3d311264f06..2d2366c1b80 100755 --- a/tools/pickman/__main__.py +++ b/tools/pickman/__main__.py @@ -9,6 +9,7 @@ import argparse import os import sys +import unittest # Allow 'from pickman import xxx' to work via symlink our_path = os.path.dirname(os.path.realpath(__file__)) @@ -16,6 +17,8 @@ sys.path.insert(0, os.path.join(our_path, '..')) # pylint: disable=wrong-import-position,import-error from pickman import control +from pickman import ftest +from u_boot_pylib import test_util def parse_args(argv): @@ -80,11 +83,65 @@ def parse_args(argv): poll_cmd.add_argument('-t', '--target', default='master', help='Target branch for MR (default: master)') - subparsers.add_parser('test', help='Run tests') + test_cmd = subparsers.add_parser('test', help='Run tests') + test_cmd.add_argument('-P', '--processes', type=int, + help='Number of processes to run tests (default: all)') + test_cmd.add_argument('-T', '--test-coverage', action='store_true', + help='Run tests and check for 100%% coverage') + test_cmd.add_argument('-v', '--verbosity', type=int, default=1, + help='Verbosity level (0-4, default: 1)') + test_cmd.add_argument('tests', nargs='*', help='Specific tests to run') return parser.parse_args(argv) +def get_test_classes(): + """Get all test classes from the ftest module. + + Returns: + list: List of test class objects + """ + return [getattr(ftest, name) for name in dir(ftest) + if name.startswith('Test') and + isinstance(getattr(ftest, name), type) and + issubclass(getattr(ftest, name), unittest.TestCase)] + + +def run_tests(processes, verbosity, test_name): + """Run the pickman test suite. + + Args: + processes (int): Number of processes for concurrent tests + verbosity (int): Verbosity level (0-4) + test_name (str): Specific test to run, or None for all + + Returns: + int: 0 if tests passed, 1 otherwise + """ + result = test_util.run_test_suites( + 'pickman', False, verbosity, False, False, processes, + test_name, None, get_test_classes()) + + return 0 if result.wasSuccessful() else 1 + + +def run_test_coverage(args): + """Run tests with coverage checking. + + Args: + args (list): Specific tests to run, or None for all + """ + # agent.py and gitlab_api.py require external services (Claude, GitLab) + # so they can't achieve 100% coverage in unit tests + test_util.run_test_coverage( + 'tools/pickman/pickman', None, + ['*test*', '*__main__.py', 'tools/u_boot_pylib/*'], + None, extra_args=None, args=args, + allow_failures=['tools/pickman/agent.py', + 'tools/pickman/gitlab_api.py', + 'tools/pickman/control.py']) + + def main(argv=None): """Main function to parse args and run commands. @@ -92,6 +149,14 @@ def main(argv=None): argv (list): Command line arguments (None for sys.argv[1:]) """ args = parse_args(argv) + + if args.cmd == 'test': + if args.test_coverage: + run_test_coverage(args.tests or None) + return 0 + test_name = args.tests[0] if args.tests else None + return run_tests(args.processes, args.verbosity, test_name) + return control.do_pickman(args) diff --git a/tools/u_boot_pylib/test_util.py b/tools/u_boot_pylib/test_util.py index 7bd12705557..b1c8740d883 100644 --- a/tools/u_boot_pylib/test_util.py +++ b/tools/u_boot_pylib/test_util.py @@ -57,7 +57,8 @@ def run_test_coverage(prog, filter_fname, exclude_list, build_dir, glob_list += exclude_list glob_list += ['*libfdt.py', '*/site-packages/*', '*/dist-packages/*'] glob_list += ['*concurrencytest*'] - test_cmd = 'test' if 'binman' in prog or 'patman' in prog else '-t' + use_test = 'binman' in prog or 'patman' in prog or 'pickman' in prog + test_cmd = 'test' if use_test else '-t' prefix = '' if build_dir: prefix = 'PYTHONPATH=$PYTHONPATH:%s/sandbox_spl/tools ' % build_dir @@ -91,13 +92,12 @@ def run_test_coverage(prog, filter_fname, exclude_list, build_dir, print(coverage) if coverage != '100%': print(stdout) - print("To get a report in 'htmlcov/index.html', type: python3-coverage html") + print("To get a report in 'htmlcov/index.html', type: " + "python3-coverage html") print('Coverage error: %s, but should be 100%%' % coverage) ok = False if not ok: if allow_failures: - # for line in lines: - # print('.', line, re.match(r'^(tools/.*py) *\d+ *(\d+) *(\d+)%$', line)) lines = [re.match(r'^(tools/.*py) *\d+ *(\d+) *\d+%$', line) for line in stdout.splitlines()] bad = [] -- 2.43.0
From: Simon Glass <simon.glass@canonical.com> Add tests to improve test coverage for control.py: - test_poll_continues_on_step_error: Test poll warning on step error - test_format_history_summary: Test history summary formatting - test_get_next_commits_with_empty_lines: Test empty line handling - test_commit_source_resolve_error: Test commit resolution failure - test_unknown_command: Test unknown command handling - test_review_with_mrs_no_comments: Test review with open MRs This improves control.py coverage from 63% to 67%. Co-developed-by: Claude Opus 4.5 <noreply@anthropic.com> Signed-off-by: Simon Glass <simon.glass@canonical.com> --- tools/pickman/ftest.py | 179 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 179 insertions(+) diff --git a/tools/pickman/ftest.py b/tools/pickman/ftest.py index cc4a5727ac4..8865296ff11 100644 --- a/tools/pickman/ftest.py +++ b/tools/pickman/ftest.py @@ -20,6 +20,7 @@ sys.path.insert(0, os.path.join(our_path, '..')) # pylint: disable=wrong-import-position,import-error,cyclic-import from u_boot_pylib import command from u_boot_pylib import terminal +from u_boot_pylib import tout from pickman import __main__ as pickman from pickman import control @@ -1363,6 +1364,184 @@ class TestPoll(unittest.TestCase): self.assertEqual(ret, 0) self.assertEqual(call_count[0], 2) + def test_poll_continues_on_step_error(self): + """Test poll continues when step returns non-zero.""" + call_count = [0] + + def mock_step(_args, _dbs): + call_count[0] += 1 + if call_count[0] >= 2: + raise KeyboardInterrupt + return 1 # Return error + + 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' + ) + with terminal.capture() as (_, stderr): + ret = control.do_poll(args, None) + + self.assertEqual(ret, 0) + self.assertIn('returned 1', stderr.getvalue()) + + +class TestFormatHistorySummary(unittest.TestCase): + """Tests for format_history_summary function.""" + + def test_format_history_summary(self): + """Test formatting history summary.""" + commits = [ + control.CommitInfo('aaa111', 'aaa111a', 'First commit', 'Author 1'), + control.CommitInfo('bbb222', 'bbb222b', 'Second commit', 'Author 2'), + ] + result = control.format_history_summary('us/next', commits, 'cherry-abc') + + self.assertIn('us/next', result) + self.assertIn('Branch: cherry-abc', result) + self.assertIn('- aaa111a First commit', result) + self.assertIn('- bbb222b Second commit', result) + + +class TestGetNextCommitsEmptyLine(unittest.TestCase): + """Tests for get_next_commits with empty lines.""" + + def setUp(self): + """Set up test fixtures.""" + fd, self.db_path = tempfile.mkstemp(suffix='.db') + os.close(fd) + os.unlink(self.db_path) + self.old_db_fname = control.DB_FNAME + control.DB_FNAME = self.db_path + database.Database.instances.clear() + + def tearDown(self): + """Clean up test fixtures.""" + control.DB_FNAME = self.old_db_fname + if os.path.exists(self.db_path): + os.unlink(self.db_path) + database.Database.instances.clear() + command.TEST_RESULT = None + + def test_get_next_commits_with_empty_lines(self): + """Test get_next_commits handles empty lines in output.""" + with terminal.capture(): + dbs = database.Database(self.db_path) + dbs.start() + dbs.source_set('us/next', 'abc123') + dbs.commit() + + # Log output with empty lines + log_output = ( + 'aaa111|aaa111a|Author 1|First commit|abc123\n' + '\n' # Empty line + 'bbb222|bbb222b|Author 2|Second commit|aaa111\n' + '\n' # Another empty line + ) + command.TEST_RESULT = command.CommandResult(stdout=log_output) + + commits, merge_found, error = control.get_next_commits(dbs, + 'us/next') + self.assertIsNone(error) + self.assertFalse(merge_found) + self.assertEqual(len(commits), 2) + dbs.close() + + +class TestDoCommitSourceResolveError(unittest.TestCase): + """Tests for do_commit_source error handling.""" + + def setUp(self): + """Set up test fixtures.""" + fd, self.db_path = tempfile.mkstemp(suffix='.db') + os.close(fd) + os.unlink(self.db_path) + self.old_db_fname = control.DB_FNAME + control.DB_FNAME = self.db_path + database.Database.instances.clear() + + def tearDown(self): + """Clean up test fixtures.""" + control.DB_FNAME = self.old_db_fname + if os.path.exists(self.db_path): + os.unlink(self.db_path) + database.Database.instances.clear() + command.TEST_RESULT = None + + def test_commit_source_resolve_error(self): + """Test commit-source fails when commit can't be resolved.""" + with terminal.capture(): + dbs = database.Database(self.db_path) + dbs.start() + dbs.source_set('us/next', 'oldcommit12345') + dbs.commit() + + database.Database.instances.clear() + + def mock_git_fail(**_kwargs): + raise command.CommandExc('git error', command.CommandResult()) + + command.TEST_RESULT = mock_git_fail + + args = argparse.Namespace(cmd='commit-source', source='us/next', + commit='badcommit') + with terminal.capture() as (_, stderr): + ret = control.do_commit_source(args, None) + self.assertEqual(ret, 1) + self.assertIn('Could not resolve', stderr.getvalue()) + + +class TestDoPickmanUnknownCommand(unittest.TestCase): + """Tests for do_pickman with unknown command.""" + + def setUp(self): + """Set up test fixtures.""" + fd, self.db_path = tempfile.mkstemp(suffix='.db') + os.close(fd) + os.unlink(self.db_path) + self.old_db_fname = control.DB_FNAME + control.DB_FNAME = self.db_path + database.Database.instances.clear() + + def tearDown(self): + """Clean up test fixtures.""" + control.DB_FNAME = self.old_db_fname + if os.path.exists(self.db_path): + os.unlink(self.db_path) + database.Database.instances.clear() + + def test_unknown_command(self): + """Test do_pickman returns 1 for unknown command.""" + args = argparse.Namespace(cmd='unknown-command') + with terminal.capture(): + ret = control.do_pickman(args) + self.assertEqual(ret, 1) + + +class TestDoReviewWithMrs(unittest.TestCase): + """Tests for do_review with open MRs.""" + + def test_review_with_mrs_no_comments(self): + """Test review with open MRs but no comments.""" + tout.init(tout.INFO) + + 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]): + with mock.patch.object(gitlab_api, 'get_mr_comments', + return_value=[]): + args = argparse.Namespace(cmd='review', remote='ci') + with terminal.capture() as (stdout, _): + ret = control.do_review(args, None) + + self.assertEqual(ret, 0) + self.assertIn('Found 1 open pickman MR', stdout.getvalue()) + if __name__ == '__main__': unittest.main() -- 2.43.0
From: Simon Glass <simon.glass@canonical.com> Extract get_history() from write_history() to separate the history content generation and file I/O from the git operations. The function now: - Takes a filename parameter - Reads existing content from the file - Writes updated content to the file - Returns both the content and the git commit message This makes the history generation logic fully testable without needing to mock git operations. Add tests for get_history(): - test_get_history_empty: Test with no existing file - test_get_history_with_existing: Test appending to existing content - test_get_history_replaces_existing_branch: Test replacing entry - test_get_history_multiple_commits: Test commit message with multiple commits Co-developed-by: Claude Opus 4.5 <noreply@anthropic.com> Signed-off-by: Simon Glass <simon.glass@canonical.com> --- tools/pickman/control.py | 44 +++++++++++---- tools/pickman/ftest.py | 114 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+), 10 deletions(-) diff --git a/tools/pickman/control.py b/tools/pickman/control.py index 474315f7db0..190f92fc57a 100644 --- a/tools/pickman/control.py +++ b/tools/pickman/control.py @@ -258,14 +258,19 @@ Commits: {commit_list}""" -def write_history(source, commits, branch_name, conversation_log): - """Write an entry to the pickman history file +def get_history(fname, source, commits, branch_name, conversation_log): + """Read, update and write history file for a cherry-pick operation Args: + fname (str): History filename to read/write source (str): Source branch name commits (list): list of CommitInfo tuples branch_name (str): Name of the cherry-pick branch conversation_log (str): The agent's conversation output + + Returns: + tuple: (content, commit_msg) where content is the updated history + and commit_msg is the git commit message """ summary = format_history_summary(source, commits, branch_name) entry = f"""{summary} @@ -277,24 +282,43 @@ def write_history(source, commits, branch_name, conversation_log): """ - # Read existing content and remove any entry for this branch + # Read existing content existing = '' - if os.path.exists(HISTORY_FILE): - with open(HISTORY_FILE, 'r', encoding='utf-8') as fhandle: + if os.path.exists(fname): + with open(fname, 'r', encoding='utf-8') as fhandle: existing = fhandle.read() # Remove existing entry for this branch (from ## header to ---) pattern = rf'## [^\n]+\n\nBranch: {re.escape(branch_name)}\n.*?---\n\n' existing = re.sub(pattern, '', existing, flags=re.DOTALL) + content = existing + entry + # Write updated history file - with open(HISTORY_FILE, 'w', encoding='utf-8') as fhandle: - fhandle.write(existing + entry) + with open(fname, 'w', encoding='utf-8') as fhandle: + fhandle.write(content) + + # Generate commit message + commit_msg = f'pickman: Record cherry-pick of {len(commits)} commits from {source}\n\n' + commit_msg += '\n'.join(f'- {c.short_hash} {c.subject}' for c in commits) + + return content, commit_msg + + +def write_history(source, commits, branch_name, conversation_log): + """Write an entry to the pickman history file and commit it + + Args: + source (str): Source branch name + commits (list): list of CommitInfo tuples + branch_name (str): Name of the cherry-pick branch + conversation_log (str): The agent's conversation output + """ + _, commit_msg = get_history(HISTORY_FILE, source, commits, branch_name, + conversation_log) # Commit the history file (use -f in case .gitignore patterns match) run_git(['add', '-f', HISTORY_FILE]) - msg = f'pickman: Record cherry-pick of {len(commits)} commits from {source}\n\n' - msg += '\n'.join(f'- {c.short_hash} {c.subject}' for c in commits) - run_git(['commit', '-m', msg]) + run_git(['commit', '-m', commit_msg]) tout.info(f'Updated {HISTORY_FILE}') diff --git a/tools/pickman/ftest.py b/tools/pickman/ftest.py index 8865296ff11..d50be16fea7 100644 --- a/tools/pickman/ftest.py +++ b/tools/pickman/ftest.py @@ -1404,6 +1404,120 @@ class TestFormatHistorySummary(unittest.TestCase): self.assertIn('- bbb222b Second commit', result) +class TestGetHistory(unittest.TestCase): + """Tests for get_history function.""" + + def setUp(self): + """Set up test fixtures.""" + fd, self.history_file = tempfile.mkstemp(suffix='.history') + os.close(fd) + os.unlink(self.history_file) # Remove so we start fresh + + def tearDown(self): + """Clean up test fixtures.""" + if os.path.exists(self.history_file): + os.unlink(self.history_file) + + def test_get_history_empty(self): + """Test get_history with no existing file.""" + commits = [ + control.CommitInfo('aaa111', 'aaa111a', 'First commit', 'Author 1'), + ] + content, commit_msg = control.get_history( + self.history_file, 'us/next', commits, 'cherry-abc', + 'Conversation output') + + self.assertIn('us/next', content) + self.assertIn('Branch: cherry-abc', content) + self.assertIn('- aaa111a First commit', content) + self.assertIn('### Conversation log', content) + self.assertIn('Conversation output', content) + self.assertIn('---', content) + + # Verify commit message + self.assertIn('pickman: Record cherry-pick of 1 commits', commit_msg) + self.assertIn('- aaa111a First commit', commit_msg) + + # Verify file was written + with open(self.history_file, 'r', encoding='utf-8') as fhandle: + file_content = fhandle.read() + self.assertEqual(file_content, content) + + def test_get_history_with_existing(self): + """Test get_history appends to existing content.""" + # Create existing file + with open(self.history_file, 'w', encoding='utf-8') as fhandle: + fhandle.write('Previous history content\n') + + commits = [ + control.CommitInfo('bbb222', 'bbb222b', 'New commit', 'Author 2'), + ] + content, commit_msg = control.get_history( + self.history_file, 'us/next', commits, 'cherry-new', + 'New conversation') + + self.assertIn('Previous history content', content) + self.assertIn('cherry-new', content) + self.assertIn('New conversation', content) + self.assertIn('- bbb222b New commit', commit_msg) + + def test_get_history_replaces_existing_branch(self): + """Test get_history removes existing entry for same branch.""" + # Create existing file with an entry for cherry-abc + existing = """## 2025-01-01: us/next + +Branch: cherry-abc + +Commits: +- aaa111a Old commit + +### Conversation log +Old conversation + +--- + +Other content +""" + with open(self.history_file, 'w', encoding='utf-8') as fhandle: + fhandle.write(existing) + + commits = [ + control.CommitInfo('ccc333', 'ccc333c', 'Updated commit', 'Author'), + ] + content, _ = control.get_history(self.history_file, 'us/next', commits, + 'cherry-abc', 'New conversation') + + # Old entry should be removed + self.assertNotIn('Old commit', content) + self.assertNotIn('Old conversation', content) + # New entry should be present + self.assertIn('Updated commit', content) + self.assertIn('New conversation', content) + # Other content should be preserved + self.assertIn('Other content', content) + + def test_get_history_multiple_commits(self): + """Test get_history with multiple commits.""" + commits = [ + control.CommitInfo('aaa111', 'aaa111a', 'First commit', 'Author 1'), + control.CommitInfo('bbb222', 'bbb222b', 'Second commit', 'Author 2'), + control.CommitInfo('ccc333', 'ccc333c', 'Third commit', 'Author 3'), + ] + content, commit_msg = control.get_history( + self.history_file, 'us/next', commits, 'cherry-abc', 'Log') + + # Verify all commits in content + self.assertIn('- aaa111a First commit', content) + self.assertIn('- bbb222b Second commit', content) + self.assertIn('- ccc333c Third commit', content) + + # Verify commit message + self.assertIn('pickman: Record cherry-pick of 3 commits', commit_msg) + self.assertIn('- aaa111a First commit', commit_msg) + self.assertIn('- bbb222b Second commit', commit_msg) + self.assertIn('- ccc333c Third commit', commit_msg) + + class TestGetNextCommitsEmptyLine(unittest.TestCase): """Tests for get_next_commits with empty lines.""" -- 2.43.0
From: Simon Glass <simon.glass@canonical.com> Extract the setup logic from do_apply() into prepare_apply() which: - Gets the next commits from the source branch - Validates the source exists and has commits - Saves the original branch - Generates or uses provided branch name - Deletes existing branch if needed - Prints info about what will be applied Returns (ApplyInfo, return_code) tuple where ApplyInfo contains commits, branch_name, original_branch, and merge_found. This makes the setup logic testable independently from the agent and git operations. Add tests for prepare_apply(): - test_prepare_apply_error: Test error handling for unknown source - test_prepare_apply_no_commits: Test when no commits to apply - test_prepare_apply_with_commits: Test successful preparation - test_prepare_apply_custom_branch: Test custom branch name Co-developed-by: Claude Opus 4.5 <noreply@anthropic.com> Signed-off-by: Simon Glass <simon.glass@canonical.com> --- tools/pickman/control.py | 48 ++++++++++++++--- tools/pickman/ftest.py | 108 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 146 insertions(+), 10 deletions(-) diff --git a/tools/pickman/control.py b/tools/pickman/control.py index 190f92fc57a..aa3fd65ec8e 100644 --- a/tools/pickman/control.py +++ b/tools/pickman/control.py @@ -39,6 +39,11 @@ Commit = namedtuple('Commit', ['hash', 'short_hash', 'subject', 'date']) CommitInfo = namedtuple('CommitInfo', ['hash', 'short_hash', 'subject', 'author']) +# Named tuple for prepare_apply result +ApplyInfo = namedtuple('ApplyInfo', + ['commits', 'branch_name', 'original_branch', + 'merge_found']) + def run_git(args): """Run a git command and return output.""" @@ -323,32 +328,37 @@ def write_history(source, commits, branch_name, conversation_log): tout.info(f'Updated {HISTORY_FILE}') -def do_apply(args, dbs): # pylint: disable=too-many-locals,too-many-branches - """Apply the next set of commits using Claude agent +def prepare_apply(dbs, source, branch): + """Prepare for applying commits from a source branch + + Gets the next commits, sets up the branch name, and prints info about + what will be applied. Args: - args (Namespace): Parsed arguments with 'source' and 'branch' attributes dbs (Database): Database instance + source (str): Source branch name + branch (str): Branch name to use, or None to auto-generate Returns: - int: 0 on success, 1 on failure + tuple: (ApplyInfo, return_code) where ApplyInfo is set if there are + commits to apply, or None with return_code indicating the result + (0 for no commits, 1 for error) """ - source = args.source commits, merge_found, error = get_next_commits(dbs, source) if error: tout.error(error) - return 1 + return None, 1 if not commits: tout.info('No new commits to cherry-pick') - return 0 + return None, 0 # Save current branch to return to later original_branch = run_git(['rev-parse', '--abbrev-ref', 'HEAD']) # Generate branch name if not provided - branch_name = args.branch + branch_name = branch if not branch_name: # Use first commit's short hash as part of branch name branch_name = f'cherry-{commits[0].short_hash}' @@ -372,6 +382,28 @@ def do_apply(args, dbs): # pylint: disable=too-many-locals,too-many-branches tout.info(f' {commit.short_hash} {commit.subject}') tout.info('') + return ApplyInfo(commits, branch_name, original_branch, merge_found), 0 + + +def do_apply(args, dbs): # pylint: disable=too-many-locals,too-many-branches + """Apply the next set of commits using Claude agent + + Args: + args (Namespace): Parsed arguments with 'source' and 'branch' attributes + dbs (Database): Database instance + + Returns: + int: 0 on success, 1 on failure + """ + source = args.source + info, ret = prepare_apply(dbs, source, args.branch) + if not info: + return ret + + commits = info.commits + branch_name = info.branch_name + original_branch = info.original_branch + # Add commits to database with 'pending' status source_id = dbs.source_get_id(source) for commit in commits: diff --git a/tools/pickman/ftest.py b/tools/pickman/ftest.py index d50be16fea7..1418211d4ae 100644 --- a/tools/pickman/ftest.py +++ b/tools/pickman/ftest.py @@ -976,7 +976,7 @@ class TestApply(unittest.TestCase): database.Database.instances.clear() - args = argparse.Namespace(cmd='apply', source='unknown') + args = argparse.Namespace(cmd='apply', source='unknown', branch=None) with terminal.capture() as (_, stderr): ret = control.do_pickman(args) self.assertEqual(ret, 1) @@ -994,7 +994,7 @@ class TestApply(unittest.TestCase): database.Database.instances.clear() command.TEST_RESULT = command.CommandResult(stdout='') - args = argparse.Namespace(cmd='apply', source='us/next') + args = argparse.Namespace(cmd='apply', source='us/next', branch=None) with terminal.capture() as (stdout, _): ret = control.do_pickman(args) self.assertEqual(ret, 0) @@ -1518,6 +1518,110 @@ Other content self.assertIn('- ccc333c Third commit', commit_msg) +class TestPrepareApply(unittest.TestCase): + """Tests for prepare_apply function.""" + + def setUp(self): + """Set up test fixtures.""" + fd, self.db_path = tempfile.mkstemp(suffix='.db') + os.close(fd) + os.unlink(self.db_path) + self.old_db_fname = control.DB_FNAME + control.DB_FNAME = self.db_path + database.Database.instances.clear() + + def tearDown(self): + """Clean up test fixtures.""" + control.DB_FNAME = self.old_db_fname + if os.path.exists(self.db_path): + os.unlink(self.db_path) + database.Database.instances.clear() + command.TEST_RESULT = None + + def test_prepare_apply_error(self): + """Test prepare_apply returns error code 1 on source not found.""" + with terminal.capture(): + dbs = database.Database(self.db_path) + dbs.start() + + info, ret = control.prepare_apply(dbs, 'unknown', None) + + self.assertIsNone(info) + self.assertEqual(ret, 1) + dbs.close() + + def test_prepare_apply_no_commits(self): + """Test prepare_apply returns code 0 when no commits.""" + with terminal.capture(): + dbs = database.Database(self.db_path) + dbs.start() + dbs.source_set('us/next', 'abc123') + dbs.commit() + + command.TEST_RESULT = command.CommandResult(stdout='') + + info, ret = control.prepare_apply(dbs, 'us/next', None) + + self.assertIsNone(info) + self.assertEqual(ret, 0) + dbs.close() + + def test_prepare_apply_with_commits(self): + """Test prepare_apply returns ApplyInfo with commits.""" + with terminal.capture(): + dbs = database.Database(self.db_path) + dbs.start() + dbs.source_set('us/next', 'abc123') + dbs.commit() + + log_output = 'aaa111|aaa111a|Author 1|First commit|abc123\n' + + def mock_git(pipe_list): + cmd = pipe_list[0] if pipe_list else [] + if 'log' in cmd: + return command.CommandResult(stdout=log_output) + if 'rev-parse' in cmd: + return command.CommandResult(stdout='master') + return command.CommandResult(stdout='') + + command.TEST_RESULT = mock_git + + info, ret = control.prepare_apply(dbs, 'us/next', None) + + self.assertIsNotNone(info) + self.assertEqual(ret, 0) + self.assertEqual(len(info.commits), 1) + self.assertEqual(info.branch_name, 'cherry-aaa111a') + self.assertEqual(info.original_branch, 'master') + dbs.close() + + def test_prepare_apply_custom_branch(self): + """Test prepare_apply uses custom branch name.""" + with terminal.capture(): + dbs = database.Database(self.db_path) + dbs.start() + dbs.source_set('us/next', 'abc123') + dbs.commit() + + log_output = 'aaa111|aaa111a|Author 1|First commit|abc123\n' + + def mock_git(pipe_list): + cmd = pipe_list[0] if pipe_list else [] + if 'log' in cmd: + return command.CommandResult(stdout=log_output) + if 'rev-parse' in cmd: + return command.CommandResult(stdout='master') + return command.CommandResult(stdout='') + + command.TEST_RESULT = mock_git + + info, _ = control.prepare_apply(dbs, 'us/next', 'my-branch') + + self.assertIsNotNone(info) + self.assertEqual(info.branch_name, 'my-branch') + dbs.close() + + class TestGetNextCommitsEmptyLine(unittest.TestCase): """Tests for get_next_commits with empty lines.""" -- 2.43.0
From: Simon Glass <simon.glass@canonical.com> Extract the core 'apply' logic into execute_apply() which handles database operations, agent invocation, and MR creation. This separates the non-git logic from do_apply() which now handles setup, history writing, and branch restoration. Also move branch restoration to the end of do_apply() using a ret variable to ensure we always restore the branch even if MR creation fails. Add tests for execute_apply() covering success, failure, push, and push failure cases. Rename conversation_log to conv_log for brevity. Co-developed-by: Claude Opus 4.5 <noreply@anthropic.com> Signed-off-by: Simon Glass <simon.glass@canonical.com> --- tools/pickman/control.py | 86 +++++++++++++++++----------- tools/pickman/ftest.py | 120 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 174 insertions(+), 32 deletions(-) diff --git a/tools/pickman/control.py b/tools/pickman/control.py index aa3fd65ec8e..973db22e106 100644 --- a/tools/pickman/control.py +++ b/tools/pickman/control.py @@ -263,7 +263,7 @@ Commits: {commit_list}""" -def get_history(fname, source, commits, branch_name, conversation_log): +def get_history(fname, source, commits, branch_name, conv_log): """Read, update and write history file for a cherry-pick operation Args: @@ -271,7 +271,7 @@ def get_history(fname, source, commits, branch_name, conversation_log): source (str): Source branch name commits (list): list of CommitInfo tuples branch_name (str): Name of the cherry-pick branch - conversation_log (str): The agent's conversation output + conv_log (str): The agent's conversation output Returns: tuple: (content, commit_msg) where content is the updated history @@ -281,7 +281,7 @@ def get_history(fname, source, commits, branch_name, conversation_log): entry = f"""{summary} ### Conversation log -{conversation_log} +{conv_log} --- @@ -309,17 +309,17 @@ def get_history(fname, source, commits, branch_name, conversation_log): return content, commit_msg -def write_history(source, commits, branch_name, conversation_log): +def write_history(source, commits, branch_name, conv_log): """Write an entry to the pickman history file and commit it Args: source (str): Source branch name commits (list): list of CommitInfo tuples branch_name (str): Name of the cherry-pick branch - conversation_log (str): The agent's conversation output + conv_log (str): The agent's conversation output """ _, commit_msg = get_history(HISTORY_FILE, source, commits, branch_name, - conversation_log) + conv_log) # Commit the history file (use -f in case .gitignore patterns match) run_git(['add', '-f', HISTORY_FILE]) @@ -385,25 +385,20 @@ def prepare_apply(dbs, source, branch): return ApplyInfo(commits, branch_name, original_branch, merge_found), 0 -def do_apply(args, dbs): # pylint: disable=too-many-locals,too-many-branches - """Apply the next set of commits using Claude agent +def execute_apply(dbs, source, commits, branch_name, args): # pylint: disable=too-many-locals + """Execute the apply operation: run agent, update database, push MR Args: - args (Namespace): Parsed arguments with 'source' and 'branch' attributes dbs (Database): Database instance + source (str): Source branch name + commits (list): List of CommitInfo namedtuples + branch_name (str): Branch name for cherry-picks + args (Namespace): Parsed arguments with 'push', 'remote', 'target' Returns: - int: 0 on success, 1 on failure + tuple: (ret, success, conv_log) where ret is 0 on success, + 1 on failure """ - source = args.source - info, ret = prepare_apply(dbs, source, args.branch) - if not info: - return ret - - commits = info.commits - branch_name = info.branch_name - original_branch = info.original_branch - # Add commits to database with 'pending' status source_id = dbs.source_get_id(source) for commit in commits: @@ -413,7 +408,7 @@ def do_apply(args, dbs): # pylint: disable=too-many-locals,too-many-branches # Convert CommitInfo to tuple format expected by agent commit_tuples = [(c.hash, c.short_hash, c.subject) for c in commits] - success, conversation_log = agent.cherry_pick_commits(commit_tuples, source, + success, conv_log = agent.cherry_pick_commits(commit_tuples, source, branch_name) # Update commit status based on result @@ -422,15 +417,7 @@ def do_apply(args, dbs): # pylint: disable=too-many-locals,too-many-branches dbs.commit_set_status(commit.hash, status) dbs.commit() - # Write history file if successful - if success: - write_history(source, commits, branch_name, conversation_log) - - # Return to original branch - current_branch = run_git(['rev-parse', '--abbrev-ref', 'HEAD']) - if current_branch != original_branch: - tout.info(f'Returning to {original_branch}') - run_git(['checkout', original_branch]) + ret = 0 if success else 1 if success: # Push and create MR if requested @@ -441,18 +428,53 @@ def do_apply(args, dbs): # pylint: disable=too-many-locals,too-many-branches title = f'[pickman] {commits[-1].subject}' # Description matches .pickman-history entry (summary + conversation) summary = format_history_summary(source, commits, branch_name) - description = f'{summary}\n\n### Conversation log\n{conversation_log}' + description = f'{summary}\n\n### Conversation log\n{conv_log}' mr_url = gitlab_api.push_and_create_mr( remote, branch_name, target, title, description ) if not mr_url: - return 1 + ret = 1 else: tout.info(f"Use 'pickman commit-source {source} " f"{commits[-1].short_hash}' to update the database") - return 0 if success else 1 + return ret, success, conv_log + + +def do_apply(args, dbs): + """Apply the next set of commits using Claude agent + + Args: + args (Namespace): Parsed arguments with 'source' and 'branch' attributes + dbs (Database): Database instance + + Returns: + int: 0 on success, 1 on failure + """ + source = args.source + info, ret = prepare_apply(dbs, source, args.branch) + if not info: + return ret + + commits = info.commits + branch_name = info.branch_name + original_branch = info.original_branch + + ret, success, conv_log = execute_apply(dbs, source, commits, + branch_name, args) + + # Write history file if successful + if success: + write_history(source, commits, branch_name, conv_log) + + # Return to original branch + current_branch = run_git(['rev-parse', '--abbrev-ref', 'HEAD']) + if current_branch != original_branch: + tout.info(f'Returning to {original_branch}') + run_git(['checkout', original_branch]) + + return ret def do_commit_source(args, dbs): diff --git a/tools/pickman/ftest.py b/tools/pickman/ftest.py index 1418211d4ae..9c65652f27a 100644 --- a/tools/pickman/ftest.py +++ b/tools/pickman/ftest.py @@ -1622,6 +1622,126 @@ class TestPrepareApply(unittest.TestCase): dbs.close() +class TestExecuteApply(unittest.TestCase): + """Tests for execute_apply function.""" + + def setUp(self): + """Set up test fixtures.""" + fd, self.db_path = tempfile.mkstemp(suffix='.db') + os.close(fd) + os.unlink(self.db_path) + self.old_db_fname = control.DB_FNAME + control.DB_FNAME = self.db_path + database.Database.instances.clear() + + def tearDown(self): + """Clean up test fixtures.""" + control.DB_FNAME = self.old_db_fname + if os.path.exists(self.db_path): + os.unlink(self.db_path) + database.Database.instances.clear() + + def test_execute_apply_success(self): + """Test execute_apply with successful cherry-pick.""" + with terminal.capture(): + dbs = database.Database(self.db_path) + dbs.start() + dbs.source_set('us/next', 'abc123') + dbs.commit() + + commits = [control.CommitInfo('aaa111', 'aaa111a', 'Test commit', + 'Author')] + args = argparse.Namespace(push=False) + + with mock.patch.object(control.agent, 'cherry_pick_commits', + return_value=(True, 'conversation log')): + ret, success, conv_log = control.execute_apply( + dbs, 'us/next', commits, 'cherry-branch', args) + + self.assertEqual(ret, 0) + self.assertTrue(success) + self.assertEqual(conv_log, 'conversation log') + + # Check commit was added to database + commit_rec = dbs.commit_get('aaa111') + self.assertIsNotNone(commit_rec) + self.assertEqual(commit_rec[6], 'applied') # status field + dbs.close() + + def test_execute_apply_failure(self): + """Test execute_apply with failed cherry-pick.""" + with terminal.capture(): + dbs = database.Database(self.db_path) + dbs.start() + dbs.source_set('us/next', 'abc123') + dbs.commit() + + commits = [control.CommitInfo('bbb222', 'bbb222b', 'Test commit', + 'Author')] + args = argparse.Namespace(push=False) + + with mock.patch.object(control.agent, 'cherry_pick_commits', + return_value=(False, 'error log')): + ret, success, _ = control.execute_apply( + dbs, 'us/next', commits, 'cherry-branch', args) + + self.assertEqual(ret, 1) + self.assertFalse(success) + + # Check commit status is conflict + commit_rec = dbs.commit_get('bbb222') + self.assertEqual(commit_rec[6], 'conflict') + dbs.close() + + def test_execute_apply_with_push(self): + """Test execute_apply with push enabled.""" + with terminal.capture(): + dbs = database.Database(self.db_path) + dbs.start() + dbs.source_set('us/next', 'abc123') + dbs.commit() + + commits = [control.CommitInfo('ccc333', 'ccc333c', 'Test commit', + 'Author')] + args = argparse.Namespace(push=True, remote='origin', + target='main') + + with mock.patch.object(control.agent, 'cherry_pick_commits', + return_value=(True, 'log')): + with mock.patch.object(gitlab_api, 'push_and_create_mr', + return_value='https://mr/url'): + ret, success, _ = control.execute_apply( + dbs, 'us/next', commits, 'cherry-branch', args) + + self.assertEqual(ret, 0) + self.assertTrue(success) + dbs.close() + + def test_execute_apply_push_fails(self): + """Test execute_apply when MR creation fails.""" + with terminal.capture(): + dbs = database.Database(self.db_path) + dbs.start() + dbs.source_set('us/next', 'abc123') + dbs.commit() + + commits = [control.CommitInfo('ddd444', 'ddd444d', 'Test commit', + 'Author')] + args = argparse.Namespace(push=True, remote='origin', + target='main') + + with mock.patch.object(control.agent, 'cherry_pick_commits', + return_value=(True, 'log')): + with mock.patch.object(gitlab_api, 'push_and_create_mr', + return_value=None): + ret, success, _ = control.execute_apply( + dbs, 'us/next', commits, 'cherry-branch', args) + + self.assertEqual(ret, 1) + self.assertTrue(success) # cherry-pick succeeded, MR failed + dbs.close() + + class TestGetNextCommitsEmptyLine(unittest.TestCase): """Tests for get_next_commits with empty lines.""" -- 2.43.0
From: Simon Glass <simon.glass@canonical.com> Change get_pickman_mrs() and get_mr_comments() to return lists of named tuples instead of dicts. This provides cleaner attribute access (e.g. merge_req.iid instead of merge_req['iid']) and better code documentation. Add PickmanMr and MrComment named tuples to gitlab_api.py. Co-developed-by: Claude Opus 4.5 <noreply@anthropic.com> Signed-off-by: Simon Glass <simon.glass@canonical.com> --- tools/pickman/agent.py | 2 +- tools/pickman/control.py | 24 +++++++++---------- tools/pickman/ftest.py | 24 +++++++++++-------- tools/pickman/gitlab_api.py | 48 ++++++++++++++++++++++--------------- 4 files changed, 56 insertions(+), 42 deletions(-) diff --git a/tools/pickman/agent.py b/tools/pickman/agent.py index 176cf9773a0..932d61be4d7 100644 --- a/tools/pickman/agent.py +++ b/tools/pickman/agent.py @@ -154,7 +154,7 @@ async def run_review_agent(mr_iid, branch_name, comments, remote, repo_path=None # Format comments for the prompt comment_text = '\n'.join( - f"- [{c['author']}]: {c['body']}" + f'- [{c.author}]: {c.body}' for c in comments ) diff --git a/tools/pickman/control.py b/tools/pickman/control.py index 973db22e106..09ffa4a3da3 100644 --- a/tools/pickman/control.py +++ b/tools/pickman/control.py @@ -528,29 +528,29 @@ def process_mr_reviews(remote, mrs): processed = 0 for merge_req in mrs: - comments = gitlab_api.get_mr_comments(remote, merge_req['iid']) + comments = gitlab_api.get_mr_comments(remote, merge_req.iid) if comments is None: continue # Filter to unresolved comments - unresolved = [c for c in comments if not c.get('resolved', True)] + unresolved = [c for c in comments if not c.resolved] if not unresolved: continue tout.info('') - tout.info(f"MR !{merge_req['iid']} has {len(unresolved)} comment(s):") + tout.info(f"MR !{merge_req.iid} has {len(unresolved)} comment(s):") for comment in unresolved: - tout.info(f" [{comment['author']}]: {comment['body'][:80]}...") + tout.info(f' [{comment.author}]: {comment.body[:80]}...') # Run agent to handle comments success, _ = agent.handle_mr_comments( - merge_req['iid'], - merge_req['source_branch'], + merge_req.iid, + merge_req.source_branch, unresolved, remote, ) if not success: - tout.error(f"Failed to handle comments for MR !{merge_req['iid']}") + tout.error(f"Failed to handle comments for MR !{merge_req.iid}") processed += 1 return processed @@ -582,7 +582,7 @@ def do_review(args, dbs): # pylint: disable=unused-argument 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(f" !{merge_req.iid}: {merge_req.title}") process_mr_reviews(remote, mrs) @@ -632,7 +632,7 @@ def process_merged_mrs(remote, source, dbs): processed = 0 for merge_req in merged_mrs: - mr_source, last_hash = parse_mr_description(merge_req['description']) + mr_source, last_hash = parse_mr_description(merge_req.description) # Only process MRs for the requested source branch if mr_source != source: @@ -648,7 +648,7 @@ def process_merged_mrs(remote, source, dbs): full_hash = run_git(['rev-parse', last_hash]) except Exception: # pylint: disable=broad-except tout.warning(f"Could not resolve commit '{last_hash}' from " - f"MR !{merge_req['iid']}") + f"MR !{merge_req.iid}") continue # Check if this commit is an ancestor of source but not of current @@ -669,7 +669,7 @@ def process_merged_mrs(remote, source, dbs): # Update database short_old = current[:12] short_new = full_hash[:12] - tout.info(f"MR !{merge_req['iid']} merged, updating '{source}': " + tout.info(f"MR !{merge_req.iid} merged, updating '{source}': " f'{short_old} -> {short_new}') dbs.source_set(source, full_hash) dbs.commit() @@ -708,7 +708,7 @@ def do_step(args, dbs): 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(f" !{merge_req.iid}: {merge_req.title}") # Process any review comments on open MRs process_mr_reviews(remote, mrs) diff --git a/tools/pickman/ftest.py b/tools/pickman/ftest.py index 9c65652f27a..4459f108496 100644 --- a/tools/pickman/ftest.py +++ b/tools/pickman/ftest.py @@ -1233,11 +1233,13 @@ class TestStep(unittest.TestCase): 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', - } + mock_mr = gitlab_api.PickmanMr( + iid=123, + title='[pickman] Test MR', + web_url='https://gitlab.com/mr/123', + source_branch='cherry-test', + description='Test', + ) with mock.patch.object(gitlab_api, 'get_merged_pickman_mrs', return_value=[]): with mock.patch.object(gitlab_api, 'get_open_pickman_mrs', @@ -1864,11 +1866,13 @@ class TestDoReviewWithMrs(unittest.TestCase): """Test review with open MRs but no comments.""" tout.init(tout.INFO) - mock_mr = { - 'iid': 123, - 'title': '[pickman] Test MR', - 'web_url': 'https://gitlab.com/mr/123', - } + mock_mr = gitlab_api.PickmanMr( + iid=123, + title='[pickman] Test MR', + web_url='https://gitlab.com/mr/123', + source_branch='cherry-test', + description='Test', + ) with mock.patch.object(gitlab_api, 'get_open_pickman_mrs', return_value=[mock_mr]): with mock.patch.object(gitlab_api, 'get_mr_comments', diff --git a/tools/pickman/gitlab_api.py b/tools/pickman/gitlab_api.py index 6720e0a1526..d0128e13f80 100644 --- a/tools/pickman/gitlab_api.py +++ b/tools/pickman/gitlab_api.py @@ -5,6 +5,7 @@ # """GitLab integration for pickman - push branches and create merge requests.""" +from collections import namedtuple import os import re import sys @@ -25,6 +26,17 @@ except ImportError: AVAILABLE = False +# Merge request info returned by get_pickman_mrs() +PickmanMr = namedtuple('PickmanMr', [ + 'iid', 'title', 'web_url', 'source_branch', 'description' +]) + +# Comment info returned by get_mr_comments() +MrComment = namedtuple('MrComment', [ + 'id', 'author', 'body', 'created_at', 'resolvable', 'resolved' +]) + + def check_available(): """Check if the python-gitlab module is available @@ -160,8 +172,7 @@ def get_pickman_mrs(remote, state='opened'): state (str): MR state ('opened', 'merged', 'closed', 'all') Returns: - list: List of dicts with 'iid', 'title', 'web_url', 'source_branch', - 'description' keys, or None on failure + list: List of PickmanMr tuples, or None on failure """ if not check_available(): return None @@ -186,13 +197,13 @@ def get_pickman_mrs(remote, state='opened'): pickman_mrs = [] for merge_req in mrs: if '[pickman]' in merge_req.title: - pickman_mrs.append({ - 'iid': merge_req.iid, - 'title': merge_req.title, - 'web_url': merge_req.web_url, - 'source_branch': merge_req.source_branch, - 'description': merge_req.description or '', - }) + pickman_mrs.append(PickmanMr( + iid=merge_req.iid, + title=merge_req.title, + web_url=merge_req.web_url, + source_branch=merge_req.source_branch, + description=merge_req.description or '', + )) return pickman_mrs except gitlab.exceptions.GitlabError as exc: tout.error(f'GitLab API error: {exc}') @@ -233,8 +244,7 @@ def get_mr_comments(remote, mr_iid): mr_iid (int): Merge request IID Returns: - list: List of dicts with 'id', 'author', 'body', 'created_at', - 'resolvable', 'resolved' keys, or None on failure + list: List of MrComment tuples, or None on failure """ if not check_available(): return None @@ -260,14 +270,14 @@ def get_mr_comments(remote, mr_iid): # Skip system notes (merge status, etc.) if note.system: continue - comments.append({ - 'id': note.id, - 'author': note.author['username'], - 'body': note.body, - 'created_at': note.created_at, - 'resolvable': getattr(note, 'resolvable', False), - 'resolved': getattr(note, 'resolved', False), - }) + comments.append(MrComment( + id=note.id, + author=note.author['username'], + body=note.body, + created_at=note.created_at, + resolvable=getattr(note, 'resolvable', False), + resolved=getattr(note, 'resolved', False), + )) return comments except gitlab.exceptions.GitlabError as exc: tout.error(f'GitLab API error: {exc}') -- 2.43.0
From: Simon Glass <simon.glass@canonical.com> Fix the logic for determining if an MR's last commit is newer than the current database position. The check should verify that current is an ancestor of the MR's commit (meaning the MR's commit is newer), not the other way around. Add a test to cover this. Co-developed-by: Claude Opus 4.5 <noreply@anthropic.com> Signed-off-by: Simon Glass <simon.glass@canonical.com> --- tools/pickman/control.py | 16 ++----- tools/pickman/ftest.py | 91 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 12 deletions(-) diff --git a/tools/pickman/control.py b/tools/pickman/control.py index 09ffa4a3da3..e6b7539ea34 100644 --- a/tools/pickman/control.py +++ b/tools/pickman/control.py @@ -651,20 +651,12 @@ def process_merged_mrs(remote, source, dbs): f"MR !{merge_req.iid}") continue - # Check if this commit is an ancestor of source but not of current - # (meaning it's newer than what we have) + # Check if this commit is newer than current (current is ancestor of it) try: - # Is last_hash reachable from source? - run_git(['merge-base', '--is-ancestor', full_hash, source]) + # Is current an ancestor of last_hash? (meaning last_hash is newer) + run_git(['merge-base', '--is-ancestor', current, full_hash]) except Exception: # pylint: disable=broad-except - continue # Not reachable, skip - - try: - # Is last_hash already at or before current? - run_git(['merge-base', '--is-ancestor', full_hash, current]) - continue # Already processed - except Exception: # pylint: disable=broad-except - pass # Not an ancestor of current, so it's newer + continue # current is not an ancestor, so last_hash is not newer # Update database short_old = current[:12] diff --git a/tools/pickman/ftest.py b/tools/pickman/ftest.py index 4459f108496..80aa73f8a5b 100644 --- a/tools/pickman/ftest.py +++ b/tools/pickman/ftest.py @@ -1885,5 +1885,96 @@ class TestDoReviewWithMrs(unittest.TestCase): self.assertIn('Found 1 open pickman MR', stdout.getvalue()) +class TestProcessMergedMrs(unittest.TestCase): + """Tests for process_merged_mrs function.""" + + def setUp(self): + """Set up test fixtures.""" + fd, self.db_path = tempfile.mkstemp(suffix='.db') + os.close(fd) + os.unlink(self.db_path) + database.Database.instances.clear() + + def tearDown(self): + """Clean up test fixtures.""" + if os.path.exists(self.db_path): + os.unlink(self.db_path) + database.Database.instances.clear() + command.TEST_RESULT = None + + def test_process_merged_mrs_updates_newer(self): + """Test that newer commits update the database.""" + with terminal.capture(): + dbs = database.Database(self.db_path) + dbs.start() + dbs.source_set('us/next', 'aaa111aaa111aaa111aaa111aaa111aaa111aaa1') + dbs.commit() + + merged_mrs = [gitlab_api.PickmanMr( + iid=100, + title='[pickman] Test MR', + description='## 2025-01-01: us/next\n\n- bbb222b Subject', + source_branch='cherry-test', + web_url='https://gitlab.com/mr/100', + )] + + def mock_git(args): + if args[0] == 'rev-parse': + return 'bbb222bbb222bbb222bbb222bbb222bbb222bbb2' + if args[0] == 'merge-base': + # current is ancestor of last_hash (newer) + return '' + return '' + + with mock.patch.object(gitlab_api, 'get_merged_pickman_mrs', + return_value=merged_mrs): + with mock.patch.object(control, 'run_git', mock_git): + processed = control.process_merged_mrs('ci', 'us/next', dbs) + + self.assertEqual(processed, 1) + new_commit = dbs.source_get('us/next') + self.assertEqual(new_commit, + 'bbb222bbb222bbb222bbb222bbb222bbb222bbb2') + + dbs.close() + + def test_process_merged_mrs_skips_older(self): + """Test that older commits don't update the database.""" + with terminal.capture(): + dbs = database.Database(self.db_path) + dbs.start() + dbs.source_set('us/next', 'bbb222bbb222bbb222bbb222bbb222bbb222bbb2') + dbs.commit() + + merged_mrs = [gitlab_api.PickmanMr( + iid=100, + title='[pickman] Test MR', + description='## 2025-01-01: us/next\n\n- aaa111a Subject', + source_branch='cherry-test', + web_url='https://gitlab.com/mr/100', + )] + + def mock_git(args): + if args[0] == 'rev-parse': + return 'aaa111aaa111aaa111aaa111aaa111aaa111aaa1' + if args[0] == 'merge-base': + # current is NOT ancestor of last_hash (older) + raise RuntimeError('Not an ancestor') + return '' + + with mock.patch.object(gitlab_api, 'get_merged_pickman_mrs', + return_value=merged_mrs): + with mock.patch.object(control, 'run_git', mock_git): + processed = control.process_merged_mrs('ci', 'us/next', dbs) + + self.assertEqual(processed, 0) + # Should remain unchanged + current = dbs.source_get('us/next') + self.assertEqual(current, + 'bbb222bbb222bbb222bbb222bbb222bbb222bbb2') + + dbs.close() + + if __name__ == '__main__': unittest.main() -- 2.43.0
From: Simon Glass <simon.glass@canonical.com> Fix get_next_commits() to use --first-parent when finding the next merge commit. Without this, git log returns commits in topological order which can find merges from different subsystems that were merged later, rather than following the mainline merge order. The fix uses a two-step approach: 1. Use --first-parent to find the next merge on the mainline 2. Get all commits up to that merge (without --first-parent to include the merged branch's commits) Co-developed-by: Claude Opus 4.5 <noreply@anthropic.com> Signed-off-by: Simon Glass <simon.glass@canonical.com> --- tools/pickman/control.py | 40 ++++++++++++++++++++++++++-------------- tools/pickman/ftest.py | 37 ++++++++++++++++++++++++++++++++----- 2 files changed, 58 insertions(+), 19 deletions(-) diff --git a/tools/pickman/control.py b/tools/pickman/control.py index e6b7539ea34..892100f3479 100644 --- a/tools/pickman/control.py +++ b/tools/pickman/control.py @@ -151,7 +151,7 @@ def get_next_commits(dbs, source): """Get the next set of commits to cherry-pick from a source Finds commits between the last cherry-picked commit and the next merge - commit in the source branch. + commit on the first-parent (mainline) chain of the source branch. Args: dbs (Database): Database instance @@ -169,20 +169,38 @@ def get_next_commits(dbs, source): if not last_commit: return None, False, f"Source '{source}' not found in database" - # Get commits between last_commit and source HEAD (oldest first) - # Format: hash|short_hash|author|subject|parents - # Using | as separator since subject may contain colons + # First, find the next merge commit on the first-parent chain + # This ensures we follow the mainline and find merges in order + fp_output = run_git([ + 'log', '--reverse', '--first-parent', '--format=%H|%h|%an|%s|%P', + f'{last_commit}..{source}' + ]) + + if not fp_output: + return [], False, None + + # Find the first merge on the first-parent chain + merge_hash = None + for line in fp_output.split('\n'): + if not line: + continue + parts = line.split('|') + parents = parts[-1].split() + if len(parents) > 1: + merge_hash = parts[0] + break + + # Now get all commits from last_commit to the merge (or end of branch) + # Without --first-parent to include commits from merged branches log_output = run_git([ 'log', '--reverse', '--format=%H|%h|%an|%s|%P', - f'{last_commit}..{source}' + f'{last_commit}..{merge_hash or source}' ]) if not log_output: return [], False, None commits = [] - merge_found = False - for line in log_output.split('\n'): if not line: continue @@ -191,16 +209,10 @@ def get_next_commits(dbs, source): short_hash = parts[1] author = parts[2] subject = '|'.join(parts[3:-1]) # Subject may contain separator - parents = parts[-1].split() commits.append(CommitInfo(commit_hash, short_hash, subject, author)) - # Check if this is a merge commit (has multiple parents) - if len(parents) > 1: - merge_found = True - break - - return commits, merge_found, None + return commits, bool(merge_hash), None def do_next_set(args, dbs): diff --git a/tools/pickman/ftest.py b/tools/pickman/ftest.py index 80aa73f8a5b..784e8dd5d1b 100644 --- a/tools/pickman/ftest.py +++ b/tools/pickman/ftest.py @@ -840,14 +840,27 @@ class TestNextSet(unittest.TestCase): database.Database.instances.clear() - # Mock git log with commits including a merge - log_output = ( + # First-parent log (to find next merge on mainline) + fp_log_output = ( 'aaa111|aaa111a|Author 1|First commit|abc123\n' 'bbb222|bbb222b|Author 2|Second commit|aaa111\n' 'ccc333|ccc333c|Author 3|Merge branch feature|bbb222 ddd444\n' 'eee555|eee555e|Author 4|After merge|ccc333\n' ) - command.TEST_RESULT = command.CommandResult(stdout=log_output) + # Full log (to get all commits up to the merge) + full_log_output = ( + 'aaa111|aaa111a|Author 1|First commit|abc123\n' + 'bbb222|bbb222b|Author 2|Second commit|aaa111\n' + 'ccc333|ccc333c|Author 3|Merge branch feature|bbb222 ddd444\n' + ) + + def mock_git(pipe_list): + cmd = pipe_list[0] if pipe_list else [] + if '--first-parent' in cmd: + return command.CommandResult(stdout=fp_log_output) + return command.CommandResult(stdout=full_log_output) + + command.TEST_RESULT = mock_git args = argparse.Namespace(cmd='next-set', source='us/next') with terminal.capture() as (stdout, _): @@ -931,11 +944,25 @@ class TestGetNextCommits(unittest.TestCase): dbs.source_set('us/next', 'abc123') dbs.commit() - log_output = ( + # First-parent log (to find next merge on mainline) + fp_log_output = ( 'aaa111|aaa111a|Author 1|First commit|abc123\n' 'bbb222|bbb222b|Author 2|Merge branch|aaa111 ccc333\n' + 'ddd444|ddd444d|Author 3|After merge|bbb222\n' ) - command.TEST_RESULT = command.CommandResult(stdout=log_output) + # Full log (to get all commits up to the merge) + full_log_output = ( + 'aaa111|aaa111a|Author 1|First commit|abc123\n' + 'bbb222|bbb222b|Author 2|Merge branch|aaa111 ccc333\n' + ) + + def mock_git(pipe_list): + cmd = pipe_list[0] if pipe_list else [] + if '--first-parent' in cmd: + return command.CommandResult(stdout=fp_log_output) + return command.CommandResult(stdout=full_log_output) + + command.TEST_RESULT = mock_git commits, merge_found, error = control.get_next_commits(dbs, 'us/next') -- 2.43.0
From: Simon Glass <simon.glass@canonical.com> Add a new next-merges command that shows the next N merges to be applied from a source branch, useful for seeing what's coming up. Co-developed-by: Claude Opus 4.5 <noreply@anthropic.com> Signed-off-by: Simon Glass <simon.glass@canonical.com> --- tools/pickman/README.rst | 7 +++ tools/pickman/__main__.py | 6 +++ tools/pickman/control.py | 51 +++++++++++++++++++ tools/pickman/ftest.py | 101 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 165 insertions(+) diff --git a/tools/pickman/README.rst b/tools/pickman/README.rst index b469470138b..a382c98eac0 100644 --- a/tools/pickman/README.rst +++ b/tools/pickman/README.rst @@ -68,6 +68,13 @@ This finds commits between the last cherry-picked commit and the next merge commit in the source branch. It stops at the merge commit since that typically represents a logical grouping of commits (e.g., a pull request). +To show the next N merges that will be applied:: + + ./tools/pickman/pickman next-merges us/next + +This shows the upcoming merge commits on the first-parent chain, useful for +seeing what's coming up. Use ``-c`` to specify the count (default 10). + To apply the next set of commits using a Claude agent:: ./tools/pickman/pickman apply us/next diff --git a/tools/pickman/__main__.py b/tools/pickman/__main__.py index 2d2366c1b80..4a60ed7eedc 100755 --- a/tools/pickman/__main__.py +++ b/tools/pickman/__main__.py @@ -56,6 +56,12 @@ def parse_args(argv): subparsers.add_parser('compare', help='Compare branches') subparsers.add_parser('list-sources', help='List tracked source branches') + next_merges = subparsers.add_parser('next-merges', + help='Show next N merges to be applied') + next_merges.add_argument('source', help='Source branch name') + next_merges.add_argument('-c', '--count', type=int, default=10, + help='Number of merges to show (default: 10)') + next_set = subparsers.add_parser('next-set', help='Show next set of commits to cherry-pick') next_set.add_argument('source', help='Source branch name') diff --git a/tools/pickman/control.py b/tools/pickman/control.py index 892100f3479..7ac0fffc67a 100644 --- a/tools/pickman/control.py +++ b/tools/pickman/control.py @@ -248,6 +248,56 @@ def do_next_set(args, dbs): return 0 +def do_next_merges(args, dbs): + """Show the next N merges to be applied from a source + + Args: + args (Namespace): Parsed arguments with 'source' and 'count' attributes + dbs (Database): Database instance + + Returns: + int: 0 on success, 1 if source not found + """ + source = args.source + count = args.count + + # Get the last cherry-picked commit from database + last_commit = dbs.source_get(source) + + if not last_commit: + tout.error(f"Source '{source}' not found in database") + return 1 + + # Find merge commits on the first-parent chain + out = run_git([ + 'log', '--reverse', '--first-parent', '--merges', + '--format=%H|%h|%s', + f'{last_commit}..{source}' + ]) + + if not out: + tout.info('No merges remaining') + return 0 + + merges = [] + for line in out.split('\n'): + if not line: + continue + parts = line.split('|', 2) + commit_hash = parts[0] + short_hash = parts[1] + subject = parts[2] if len(parts) > 2 else '' + merges.append((commit_hash, short_hash, subject)) + if len(merges) >= count: + break + + tout.info(f'Next {len(merges)} merges from {source}:') + for i, (_, short_hash, subject) in enumerate(merges, 1): + tout.info(f' {i}. {short_hash} {subject}') + + return 0 + + HISTORY_FILE = '.pickman-history' @@ -786,6 +836,7 @@ COMMANDS = { 'commit-source': do_commit_source, 'compare': do_compare, 'list-sources': do_list_sources, + 'next-merges': do_next_merges, 'next-set': do_next_set, 'poll': do_poll, 'review': do_review, diff --git a/tools/pickman/ftest.py b/tools/pickman/ftest.py index 784e8dd5d1b..d452fc823ca 100644 --- a/tools/pickman/ftest.py +++ b/tools/pickman/ftest.py @@ -904,6 +904,107 @@ class TestNextSet(unittest.TestCase): self.assertIn('bbb222b Second commit', output) +class TestNextMerges(unittest.TestCase): + """Tests for next-merges command.""" + + def setUp(self): + """Set up test fixtures.""" + fd, self.db_path = tempfile.mkstemp(suffix='.db') + os.close(fd) + os.unlink(self.db_path) + self.old_db_fname = control.DB_FNAME + control.DB_FNAME = self.db_path + database.Database.instances.clear() + + def tearDown(self): + """Clean up test fixtures.""" + control.DB_FNAME = self.old_db_fname + if os.path.exists(self.db_path): + os.unlink(self.db_path) + database.Database.instances.clear() + command.TEST_RESULT = None + + def test_next_merges(self): + """Test next-merges shows upcoming merges""" + # Add source to database + with terminal.capture(): + dbs = database.Database(self.db_path) + dbs.start() + dbs.source_set('us/next', 'abc123') + dbs.commit() + dbs.close() + + database.Database.instances.clear() + + # Mock git log with merge commits + log_output = ( + 'aaa111|aaa111a|Merge branch feature-1\n' + 'bbb222|bbb222b|Merge branch feature-2\n' + 'ccc333|ccc333c|Merge branch feature-3\n' + ) + command.TEST_RESULT = command.CommandResult(stdout=log_output) + + args = argparse.Namespace(cmd='next-merges', source='us/next', count=10) + with terminal.capture() as (stdout, _): + ret = control.do_pickman(args) + self.assertEqual(ret, 0) + output = stdout.getvalue() + self.assertIn('Next 3 merges from us/next:', output) + self.assertIn('1. aaa111a Merge branch feature-1', output) + self.assertIn('2. bbb222b Merge branch feature-2', output) + self.assertIn('3. ccc333c Merge branch feature-3', output) + + def test_next_merges_with_count(self): + """Test next-merges respects count parameter""" + # Add source to database + with terminal.capture(): + dbs = database.Database(self.db_path) + dbs.start() + dbs.source_set('us/next', 'abc123') + dbs.commit() + dbs.close() + + database.Database.instances.clear() + + # Mock git log with merge commits + log_output = ( + 'aaa111|aaa111a|Merge branch feature-1\n' + 'bbb222|bbb222b|Merge branch feature-2\n' + 'ccc333|ccc333c|Merge branch feature-3\n' + ) + command.TEST_RESULT = command.CommandResult(stdout=log_output) + + args = argparse.Namespace(cmd='next-merges', source='us/next', count=2) + with terminal.capture() as (stdout, _): + ret = control.do_pickman(args) + self.assertEqual(ret, 0) + output = stdout.getvalue() + self.assertIn('Next 2 merges from us/next:', output) + self.assertIn('1. aaa111a', output) + self.assertIn('2. bbb222b', output) + self.assertNotIn('3. ccc333c', output) + + def test_next_merges_no_merges(self): + """Test next-merges with no merges remaining""" + # Add source to database + with terminal.capture(): + dbs = database.Database(self.db_path) + dbs.start() + dbs.source_set('us/next', 'abc123') + dbs.commit() + dbs.close() + + database.Database.instances.clear() + + command.TEST_RESULT = command.CommandResult(stdout='') + + args = argparse.Namespace(cmd='next-merges', source='us/next', count=10) + with terminal.capture() as (stdout, _): + ret = control.do_pickman(args) + self.assertEqual(ret, 0) + self.assertIn('No merges remaining', stdout.getvalue()) + + class TestGetNextCommits(unittest.TestCase): """Tests for get_next_commits function.""" -- 2.43.0
From: Simon Glass <simon.glass@canonical.com> The regex for extracting commit hashes from MR descriptions was matching short numbers like "1" from the conversation log (e.g., "- 1 board built"). Fix by requiring commit hashes to be at least 7 characters, which is the minimum length for git short hashes. Shorten the argument name to 'desc' while we are here. Co-developed-by: Claude Opus 4.5 <noreply@anthropic.com> Signed-off-by: Simon Glass <simon.glass@canonical.com> --- tools/pickman/control.py | 10 +++++----- tools/pickman/ftest.py | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/tools/pickman/control.py b/tools/pickman/control.py index 7ac0fffc67a..acb3b80a4a7 100644 --- a/tools/pickman/control.py +++ b/tools/pickman/control.py @@ -651,23 +651,23 @@ def do_review(args, dbs): # pylint: disable=unused-argument return 0 -def parse_mr_description(description): +def parse_mr_description(desc): """Parse a pickman MR description to extract source and last commit Args: - description (str): MR description text + desc (str): MR description text Returns: tuple: (source_branch, last_commit_hash) or (None, None) if not parseable """ # Extract source branch from "## date: source_branch" line - source_match = re.search(r'^## [^:]+: (.+)$', description, re.MULTILINE) + source_match = re.search(r'^## [^:]+: (.+)$', desc, re.MULTILINE) if not source_match: return None, None source = source_match.group(1) - # Extract commits from "- hash subject" lines - commit_matches = re.findall(r'^- ([a-f0-9]+) ', description, re.MULTILINE) + # Extract commits from '- hash subject' lines (must be at least 7 chars) + commit_matches = re.findall(r'^- ([a-f0-9]{7,}) ', desc, re.MULTILINE) if not commit_matches: return None, None diff --git a/tools/pickman/ftest.py b/tools/pickman/ftest.py index d452fc823ca..ab4cceccac7 100644 --- a/tools/pickman/ftest.py +++ b/tools/pickman/ftest.py @@ -1355,6 +1355,24 @@ Branch: cherry-abc""" self.assertIsNone(source) self.assertIsNone(last_hash) + def test_parse_mr_description_ignores_short_hashes(self): + """Test that short numbers in conversation log are not matched.""" + description = """## 2025-01-15: us/next + +Branch: cherry-abc123 + +Commits: +- abc123a First commit +- def456b Second commit + +### Conversation log +- 1 board built (sandbox) +- 2 tests passed""" + source, last_hash = control.parse_mr_description(description) + self.assertEqual(source, 'us/next') + # Should match def456b, not "1" or "2" from conversation log + self.assertEqual(last_hash, 'def456b') + class TestStep(unittest.TestCase): """Tests for step command.""" -- 2.43.0
From: Simon Glass <simon.glass@canonical.com> Add schema v3 with a processed_comment table to track which MR comments have been addressed by the review agent. This prevents re-processing the same comments when running review or poll commands. Co-developed-by: Claude Opus 4.5 <noreply@anthropic.com> Signed-off-by: Simon Glass <simon.glass@canonical.com> --- tools/pickman/README.rst | 11 ++++++ tools/pickman/database.py | 59 +++++++++++++++++++++++++++- tools/pickman/ftest.py | 82 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 151 insertions(+), 1 deletion(-) diff --git a/tools/pickman/README.rst b/tools/pickman/README.rst index a382c98eac0..a9d6809d074 100644 --- a/tools/pickman/README.rst +++ b/tools/pickman/README.rst @@ -222,6 +222,17 @@ Tables - ``url``: URL to the merge request - ``created_at``: Timestamp when the MR was created +**comment** + Tracks MR comments that have been processed by the review agent. + + - ``id``: Primary key + - ``mr_iid``: GitLab merge request IID + - ``comment_id``: GitLab comment/note ID + - ``processed_at``: Timestamp when the comment was processed + + This table prevents the same comment from being addressed multiple times + when running ``review`` or ``poll`` commands. + Configuration ------------- diff --git a/tools/pickman/database.py b/tools/pickman/database.py index c8ed8a6df09..b8da21caf58 100644 --- a/tools/pickman/database.py +++ b/tools/pickman/database.py @@ -11,6 +11,7 @@ To adjust the schema, increment LATEST, create a _migrate_to_v<x>() function and add code in migrate_to() to call it. """ +from datetime import datetime import os import sqlite3 @@ -18,7 +19,7 @@ from u_boot_pylib import tools from u_boot_pylib import tout # Schema version (version 0 means there is no database yet) -LATEST = 2 +LATEST = 3 # Default database filename DB_FNAME = '.pickman.db' @@ -129,6 +130,17 @@ class Database: # pylint: disable=too-many-public-methods 'created_at TEXT, ' 'FOREIGN KEY (source_id) REFERENCES source(id))') + def _create_v3(self): + """Migrate database to v3 schema - add comment table""" + # Table for tracking processed MR comments + self.cur.execute( + 'CREATE TABLE comment (' + 'id INTEGER PRIMARY KEY AUTOINCREMENT, ' + 'mr_iid INTEGER, ' + 'comment_id INTEGER, ' + 'processed_at TEXT, ' + 'UNIQUE(mr_iid, comment_id))') + def migrate_to(self, dest_version): """Migrate the database to the selected version @@ -151,6 +163,8 @@ class Database: # pylint: disable=too-many-public-methods self._create_v1() elif version == 2: self._create_v2() + elif version == 3: + self._create_v3() self.cur.execute('DELETE FROM schema_version') self.cur.execute( @@ -413,3 +427,46 @@ class Database: # pylint: disable=too-many-public-methods """ self.execute( 'UPDATE mergereq SET status = ? WHERE mr_id = ?', (status, mr_id)) + + # comment functions + + def comment_is_processed(self, mr_iid, comment_id): + """Check if a comment has been processed + + Args: + mr_iid (int): Merge request IID + comment_id (int): Comment ID + + Return: + bool: True if already processed + """ + res = self.execute( + 'SELECT id FROM comment WHERE mr_iid = ? AND comment_id = ?', + (mr_iid, comment_id)) + return res.fetchone() is not None + + def comment_mark_processed(self, mr_iid, comment_id): + """Mark a comment as processed + + Args: + mr_iid (int): Merge request IID + comment_id (int): Comment ID + """ + self.execute( + 'INSERT OR IGNORE INTO comment ' + '(mr_iid, comment_id, processed_at) VALUES (?, ?, ?)', + (mr_iid, comment_id, datetime.now().isoformat())) + + def comment_get_processed(self, mr_iid): + """Get all processed comment IDs for an MR + + Args: + mr_iid (int): Merge request IID + + Return: + list: List of comment IDs + """ + res = self.execute( + 'SELECT comment_id FROM comment WHERE mr_iid = ?', + (mr_iid,)) + return [row[0] for row in res.fetchall()] diff --git a/tools/pickman/ftest.py b/tools/pickman/ftest.py index ab4cceccac7..ae4ceaceaaa 100644 --- a/tools/pickman/ftest.py +++ b/tools/pickman/ftest.py @@ -721,6 +721,88 @@ class TestDatabaseCommitMergereq(unittest.TestCase): dbs.close() +class TestDatabaseComment(unittest.TestCase): + """Tests for Database comment functions.""" + + def setUp(self): + """Set up test fixtures.""" + fd, self.db_path = tempfile.mkstemp(suffix='.db') + os.close(fd) + os.unlink(self.db_path) + + def tearDown(self): + """Clean up test fixtures.""" + if os.path.exists(self.db_path): + os.unlink(self.db_path) + database.Database.instances.clear() + + def test_comment_mark_and_check_processed(self): + """Test marking and checking processed comments""" + with terminal.capture(): + dbs = database.Database(self.db_path) + dbs.start() + + # Comment should not be processed initially + self.assertFalse(dbs.comment_is_processed(123, 456)) + + # Mark as processed + dbs.comment_mark_processed(123, 456) + dbs.commit() + + # Now should be processed + self.assertTrue(dbs.comment_is_processed(123, 456)) + + # Different comment should not be processed + self.assertFalse(dbs.comment_is_processed(123, 789)) + self.assertFalse(dbs.comment_is_processed(999, 456)) + + dbs.close() + + def test_comment_get_processed(self): + """Test getting all processed comments for an MR""" + with terminal.capture(): + dbs = database.Database(self.db_path) + dbs.start() + + # Mark several comments as processed + dbs.comment_mark_processed(100, 1) + dbs.comment_mark_processed(100, 2) + dbs.comment_mark_processed(100, 3) + dbs.comment_mark_processed(200, 10) # Different MR + dbs.commit() + + # Get processed for MR 100 + processed = dbs.comment_get_processed(100) + self.assertEqual(len(processed), 3) + self.assertIn(1, processed) + self.assertIn(2, processed) + self.assertIn(3, processed) + self.assertNotIn(10, processed) + + # Get processed for MR 200 + processed = dbs.comment_get_processed(200) + self.assertEqual(len(processed), 1) + self.assertIn(10, processed) + + dbs.close() + + def test_comment_mark_processed_idempotent(self): + """Test that marking same comment twice doesn't fail""" + with terminal.capture(): + dbs = database.Database(self.db_path) + dbs.start() + + # Mark same comment twice (should not raise) + dbs.comment_mark_processed(123, 456) + dbs.comment_mark_processed(123, 456) + dbs.commit() + + # Should still be processed + self.assertTrue(dbs.comment_is_processed(123, 456)) + + dbs.close() + + class TestListSources(unittest.TestCase): """Tests for list-sources command.""" -- 2.43.0
From: Simon Glass <simon.glass@canonical.com> Add function to update a merge request's description via the GitLab API. This is used to update the MR with the agent's conversation log after processing review comments. Co-developed-by: Claude Opus 4.5 <noreply@anthropic.com> Signed-off-by: Simon Glass <simon.glass@canonical.com> --- tools/pickman/ftest.py | 42 +++++++++++++++++++++++++++++++++++++ tools/pickman/gitlab_api.py | 37 ++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/tools/pickman/ftest.py b/tools/pickman/ftest.py index ae4ceaceaaa..ec9e7e91d65 100644 --- a/tools/pickman/ftest.py +++ b/tools/pickman/ftest.py @@ -1345,6 +1345,48 @@ class TestCheckAvailable(unittest.TestCase): self.assertTrue(result) +class TestUpdateMrDescription(unittest.TestCase): + """Tests for update_mr_description function.""" + + @mock.patch.object(gitlab_api, 'get_remote_url') + @mock.patch.object(gitlab_api, 'get_token') + @mock.patch.object(gitlab_api, 'AVAILABLE', True) + def test_update_mr_description_success(self, mock_token, mock_url): + """Test successful MR description update.""" + mock_token.return_value = 'test-token' + mock_url.return_value = 'git@gitlab.com:group/project.git' + + mock_mr = mock.MagicMock() + mock_project = mock.MagicMock() + mock_project.mergerequests.get.return_value = mock_mr + + with mock.patch('gitlab.Gitlab') as mock_gitlab: + mock_gitlab.return_value.projects.get.return_value = mock_project + + result = gitlab_api.update_mr_description('origin', 123, + 'New description') + + self.assertTrue(result) + self.assertEqual(mock_mr.description, 'New description') + mock_mr.save.assert_called_once() + + @mock.patch.object(gitlab_api, 'AVAILABLE', False) + def test_update_mr_description_not_available(self): + """Test update_mr_description when gitlab not available.""" + with terminal.capture(): + result = gitlab_api.update_mr_description('origin', 123, 'desc') + self.assertFalse(result) + + @mock.patch.object(gitlab_api, 'get_token') + @mock.patch.object(gitlab_api, 'AVAILABLE', True) + def test_update_mr_description_no_token(self, mock_token): + """Test update_mr_description when no token set.""" + mock_token.return_value = None + with terminal.capture(): + result = gitlab_api.update_mr_description('origin', 123, 'desc') + self.assertFalse(result) + + class TestParseApplyWithPush(unittest.TestCase): """Tests for apply command with push options.""" diff --git a/tools/pickman/gitlab_api.py b/tools/pickman/gitlab_api.py index d0128e13f80..508168aa75c 100644 --- a/tools/pickman/gitlab_api.py +++ b/tools/pickman/gitlab_api.py @@ -320,6 +320,43 @@ def reply_to_mr(remote, mr_iid, message): return False +def update_mr_description(remote, mr_iid, desc): + """Update a merge request's description + + Args: + remote (str): Remote name + mr_iid (int): Merge request IID + desc (str): New description + + Returns: + bool: True on success + """ + if not check_available(): + return False + + token = get_token() + if not token: + tout.error('GITLAB_TOKEN environment variable not set') + return False + + remote_url = get_remote_url(remote) + host, proj_path = parse_url(remote_url) + + if not host or not proj_path: + return False + + try: + glab = gitlab.Gitlab(f'https://{host}', private_token=token) + project = glab.projects.get(proj_path) + merge_req = project.mergerequests.get(mr_iid) + merge_req.description = desc + merge_req.save() + return True + except gitlab.exceptions.GitlabError as exc: + tout.error(f'GitLab API error: {exc}') + return False + + def push_and_create_mr(remote, branch, target, title, desc=''): """Push a branch and create a merge request -- 2.43.0
From: Simon Glass <simon.glass@canonical.com> Update the review agent to: - Create local branch with version suffix (-v2, -v3, etc.) - Force push to original remote branch to update existing MR - Use --keep-empty when rebasing to preserve empty merge commits After processing comments: - Mark them as processed in database to avoid reprocessing - Update MR description with agent conversation log - Append review handling to .pickman-history Co-developed-by: Claude Opus 4.5 <noreply@anthropic.com> Signed-off-by: Simon Glass <simon.glass@canonical.com> --- tools/pickman/README.rst | 14 +++- tools/pickman/agent.py | 13 ++-- tools/pickman/control.py | 98 ++++++++++++++++++++++---- tools/pickman/ftest.py | 146 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 251 insertions(+), 20 deletions(-) diff --git a/tools/pickman/README.rst b/tools/pickman/README.rst index a9d6809d074..d94d399ab4d 100644 --- a/tools/pickman/README.rst +++ b/tools/pickman/README.rst @@ -119,8 +119,18 @@ To check open MRs for comments and address them:: ./tools/pickman/pickman review This lists open pickman MRs (those with ``[pickman]`` in the title), checks each -for unresolved comments, and uses a Claude agent to address them. The agent will -make code changes based on the feedback and push an updated branch. +for unresolved comments, and uses a Claude agent to address them. The agent will: + +- Make code changes based on the feedback +- Create a local branch with version suffix (e.g., ``cherry-abc123-v2``) +- Force push to the original remote branch to update the existing MR +- Use ``--keep-empty`` when rebasing to preserve empty merge commits + +After processing, pickman: + +- Marks comments as processed in the database (to avoid reprocessing) +- Updates the MR description with the agent's conversation log +- Appends the review handling to ``.pickman-history`` Options for the review command: diff --git a/tools/pickman/agent.py b/tools/pickman/agent.py index 932d61be4d7..e63248a1150 100644 --- a/tools/pickman/agent.py +++ b/tools/pickman/agent.py @@ -168,16 +168,19 @@ Steps to follow: 3. For each actionable comment: - Make the requested changes to the code - Amend the relevant commit or create a fixup commit -4. Run 'buildman -L --board sandbox -w -o /tmp/pickman' to verify the build -5. Create a new branch with suffix '-v2' (or increment existing version) -6. Push the new branch: git push {remote} <new-branch-name> +4. Run 'crosfw sandbox -L' to verify the build +5. Create a local branch with suffix '-v2' (or increment: -v3, -v4, etc.) +6. Force push to the ORIGINAL remote branch to update the MR: + git push --force-with-lease {remote} HEAD:{branch_name} 7. Report what changes were made and what reply should be posted to the MR Important: - Keep changes minimal and focused on addressing the comments - If a comment is unclear or cannot be addressed, note this in your report -- Do not force push to the original branch -- The new branch name should be: {branch_name}-v2 (or -v3, -v4 etc if needed) +- Local branch: {branch_name}-v2 (or -v3, -v4 etc.) +- Remote push: always to '{branch_name}' to update the existing MR +- If rebasing is requested, use: git rebase --keep-empty <base> + This preserves empty merge commits which are important for tracking """ options = ClaudeAgentOptions( diff --git a/tools/pickman/control.py b/tools/pickman/control.py index acb3b80a4a7..a6c371f0500 100644 --- a/tools/pickman/control.py +++ b/tools/pickman/control.py @@ -574,15 +574,16 @@ def do_commit_source(args, dbs): return 0 -def process_mr_reviews(remote, mrs): +def process_mr_reviews(remote, mrs, dbs): """Process review comments on open MRs Checks each MR for unresolved comments and uses Claude agent to address - them. + them. Updates MR description and .pickman-history with conversation log. Args: remote (str): Remote name mrs (list): List of MR dicts from get_open_pickman_mrs() + dbs (Database): Database instance for tracking processed comments Returns: int: Number of MRs with comments processed @@ -590,35 +591,106 @@ def process_mr_reviews(remote, mrs): processed = 0 for merge_req in mrs: - comments = gitlab_api.get_mr_comments(remote, merge_req.iid) + mr_iid = merge_req.iid + comments = gitlab_api.get_mr_comments(remote, mr_iid) if comments is None: continue - # Filter to unresolved comments - unresolved = [c for c in comments if not c.resolved] + # Filter to unresolved comments that haven't been processed + unresolved = [] + for com in comments: + if com.resolved: + continue + if dbs.comment_is_processed(mr_iid, com.id): + continue + unresolved.append(com) if not unresolved: continue tout.info('') - tout.info(f"MR !{merge_req.iid} has {len(unresolved)} comment(s):") + tout.info(f"MR !{mr_iid} has {len(unresolved)} new comment(s):") for comment in unresolved: tout.info(f' [{comment.author}]: {comment.body[:80]}...') # Run agent to handle comments - success, _ = agent.handle_mr_comments( - merge_req.iid, + success, conversation_log = agent.handle_mr_comments( + mr_iid, merge_req.source_branch, unresolved, remote, ) - if not success: - tout.error(f"Failed to handle comments for MR !{merge_req.iid}") + + if success: + # Mark comments as processed + for comment in unresolved: + dbs.comment_mark_processed(mr_iid, comment.id) + dbs.commit() + + # Update MR description with comments and conversation log + old_desc = merge_req.description + comment_summary = '\n'.join( + f"- [{c.author}]: {c.body}" + for c in unresolved + ) + new_desc = (f"{old_desc}\n\n### Review response\n\n" + f"**Comments addressed:**\n{comment_summary}\n\n" + f"**Response:**\n{conversation_log}") + gitlab_api.update_mr_description(remote, mr_iid, new_desc) + + # Update .pickman-history + update_history_with_review(merge_req.source_branch, + unresolved, conversation_log) + + tout.info(f'Updated MR !{mr_iid} description and history') + else: + tout.error(f"Failed to handle comments for MR !{mr_iid}") processed += 1 return processed -def do_review(args, dbs): # pylint: disable=unused-argument +def update_history_with_review(branch_name, comments, conversation_log): + """Append review handling to .pickman-history + + Args: + branch_name (str): Branch name for the MR + comments (list): List of comments that were addressed + conversation_log (str): Agent conversation log + """ + comment_summary = '\n'.join( + f"- [{c.author}]: {c.body[:100]}..." + for c in comments + ) + + entry = f"""### Review: {date.today()} + +Branch: {branch_name} + +Comments addressed: +{comment_summary} + +### Conversation log +{conversation_log} + +--- + +""" + + # Append to history file + existing = '' + if os.path.exists(HISTORY_FILE): + with open(HISTORY_FILE, 'r', encoding='utf-8') as fhandle: + existing = fhandle.read() + + with open(HISTORY_FILE, 'w', encoding='utf-8') as fhandle: + fhandle.write(existing + entry) + + # Commit the history file + run_git(['add', '-f', HISTORY_FILE]) + run_git(['commit', '-m', f'pickman: Record review handling for {branch_name}']) + + +def do_review(args, dbs): """Check open pickman MRs and handle comments Lists open MRs created by pickman, checks for human comments, and uses @@ -646,7 +718,7 @@ def do_review(args, dbs): # pylint: disable=unused-argument for merge_req in mrs: tout.info(f" !{merge_req.iid}: {merge_req.title}") - process_mr_reviews(remote, mrs) + process_mr_reviews(remote, mrs, dbs) return 0 @@ -765,7 +837,7 @@ def do_step(args, dbs): tout.info(f" !{merge_req.iid}: {merge_req.title}") # Process any review comments on open MRs - process_mr_reviews(remote, mrs) + process_mr_reviews(remote, mrs, dbs) tout.info('') tout.info('Not creating new MR while others are pending') diff --git a/tools/pickman/ftest.py b/tools/pickman/ftest.py index ec9e7e91d65..3fbb4e20dd7 100644 --- a/tools/pickman/ftest.py +++ b/tools/pickman/ftest.py @@ -8,6 +8,8 @@ import argparse import os +import shutil +import subprocess import sys import tempfile import unittest @@ -23,6 +25,7 @@ from u_boot_pylib import terminal from u_boot_pylib import tout from pickman import __main__ as pickman +from pickman import agent from pickman import control from pickman import database from pickman import gitlab_api @@ -1586,6 +1589,149 @@ class TestReview(unittest.TestCase): self.assertEqual(ret, 1) +class TestUpdateHistoryWithReview(unittest.TestCase): + """Tests for update_history_with_review function.""" + + def setUp(self): + """Set up test fixtures.""" + self.test_dir = tempfile.mkdtemp() + self.orig_dir = os.getcwd() + os.chdir(self.test_dir) + + # Initialize git repo + subprocess.run(['git', 'init'], check=True, capture_output=True) + subprocess.run(['git', 'config', 'user.email', 'test@test.com'], + check=True, capture_output=True) + subprocess.run(['git', 'config', 'user.name', 'Test'], + check=True, capture_output=True) + + def tearDown(self): + """Clean up test fixtures.""" + os.chdir(self.orig_dir) + shutil.rmtree(self.test_dir) + + def test_update_history_with_review(self): + """Test that review handling is appended to history.""" + comments = [ + gitlab_api.MrComment(id=1, author='reviewer1', + body='Please fix the indentation here', + created_at='2025-01-01', resolvable=True, + resolved=False), + gitlab_api.MrComment(id=2, author='reviewer2', body='Add a docstring', + created_at='2025-01-01', resolvable=True, + resolved=False), + ] + conversation_log = 'Fixed indentation and added docstring.' + + control.update_history_with_review('cherry-abc123', comments, + conversation_log) + + # Check history file was created + self.assertTrue(os.path.exists(control.HISTORY_FILE)) + + with open(control.HISTORY_FILE, 'r', encoding='utf-8') as fhandle: + content = fhandle.read() + + self.assertIn('### Review:', content) + self.assertIn('Branch: cherry-abc123', content) + self.assertIn('reviewer1', content) + self.assertIn('reviewer2', content) + self.assertIn('Fixed indentation', content) + + def test_update_history_appends(self): + """Test that review handling appends to existing history.""" + # Create existing history + with open(control.HISTORY_FILE, 'w', encoding='utf-8') as fhandle: + fhandle.write('Existing history content\n') + subprocess.run(['git', 'add', control.HISTORY_FILE], + check=True, capture_output=True) + subprocess.run(['git', 'commit', '-m', 'Initial'], + check=True, capture_output=True) + + comments = [gitlab_api.MrComment(id=1, author='reviewer', body='Fix this', + created_at='2025-01-01', resolvable=True, + resolved=False)] + control.update_history_with_review('cherry-xyz', comments, 'Fixed it') + + with open(control.HISTORY_FILE, 'r', encoding='utf-8') as fhandle: + content = fhandle.read() + + self.assertIn('Existing history content', content) + self.assertIn('### Review:', content) + + +class TestProcessMrReviewsCommentTracking(unittest.TestCase): + """Tests for comment tracking in process_mr_reviews.""" + + def setUp(self): + """Set up test fixtures.""" + fd, self.db_path = tempfile.mkstemp(suffix='.db') + os.close(fd) + os.unlink(self.db_path) + self.test_dir = tempfile.mkdtemp() + self.orig_dir = os.getcwd() + os.chdir(self.test_dir) + + # Initialize git repo + subprocess.run(['git', 'init'], check=True, capture_output=True) + subprocess.run(['git', 'config', 'user.email', 'test@test.com'], + check=True, capture_output=True) + subprocess.run(['git', 'config', 'user.name', 'Test'], + check=True, capture_output=True) + + def tearDown(self): + """Clean up test fixtures.""" + os.chdir(self.orig_dir) + shutil.rmtree(self.test_dir) + if os.path.exists(self.db_path): + os.unlink(self.db_path) + database.Database.instances.clear() + + def test_skips_processed_comments(self): + """Test that already-processed comments are skipped.""" + with terminal.capture(): + dbs = database.Database(self.db_path) + dbs.start() + + # Mark comment as processed + dbs.comment_mark_processed(100, 1) + dbs.commit() + + mrs = [gitlab_api.PickmanMr( + iid=100, + title='[pickman] Test MR', + source_branch='cherry-test', + description='Test', + web_url='https://gitlab.com/mr/100', + )] + + # Comment 1 is processed, comment 2 is new + comments = [ + gitlab_api.MrComment(id=1, author='reviewer', body='Old comment', + created_at='2025-01-01', resolvable=True, + resolved=False), + gitlab_api.MrComment(id=2, author='reviewer', body='New comment', + created_at='2025-01-01', resolvable=True, + resolved=False), + ] + + with mock.patch.object(gitlab_api, 'get_mr_comments', + return_value=comments): + with mock.patch.object(agent, 'handle_mr_comments', + return_value=(True, 'Done')) as mock_agent: + with mock.patch.object(gitlab_api, 'update_mr_description'): + with mock.patch.object(control, 'update_history_with_review'): + control.process_mr_reviews('ci', mrs, dbs) + + # Agent should only receive the new comment + call_args = mock_agent.call_args + passed_comments = call_args[0][2] + self.assertEqual(len(passed_comments), 1) + self.assertEqual(passed_comments[0].id, 2) + + dbs.close() + + class TestParsePoll(unittest.TestCase): """Tests for poll command argument parsing.""" -- 2.43.0
From: Simon Glass <simon.glass@canonical.com> Add command to show the total number of merge commits remaining to be processed from a source branch. This helps track progress through a large backlog of merges. Co-developed-by: Claude Opus 4.5 <noreply@anthropic.com> Signed-off-by: Simon Glass <simon.glass@canonical.com> --- tools/pickman/README.rst | 7 ++++ tools/pickman/__main__.py | 5 +++ tools/pickman/control.py | 36 +++++++++++++++++ tools/pickman/ftest.py | 83 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 131 insertions(+) diff --git a/tools/pickman/README.rst b/tools/pickman/README.rst index d94d399ab4d..4b99de552b2 100644 --- a/tools/pickman/README.rst +++ b/tools/pickman/README.rst @@ -68,6 +68,13 @@ This finds commits between the last cherry-picked commit and the next merge commit in the source branch. It stops at the merge commit since that typically represents a logical grouping of commits (e.g., a pull request). +To count the total remaining merges to process:: + + ./tools/pickman/pickman count-merges us/next + +This shows how many merge commits remain on the first-parent chain between the +last cherry-picked commit and the source branch tip. + To show the next N merges that will be applied:: ./tools/pickman/pickman next-merges us/next diff --git a/tools/pickman/__main__.py b/tools/pickman/__main__.py index 4a60ed7eedc..1258a0835c2 100755 --- a/tools/pickman/__main__.py +++ b/tools/pickman/__main__.py @@ -54,6 +54,11 @@ def parse_args(argv): commit_src.add_argument('commit', help='Commit hash to record') subparsers.add_parser('compare', help='Compare branches') + + count_merges = subparsers.add_parser('count-merges', + help='Count remaining merges to process') + count_merges.add_argument('source', help='Source branch name') + subparsers.add_parser('list-sources', help='List tracked source branches') next_merges = subparsers.add_parser('next-merges', diff --git a/tools/pickman/control.py b/tools/pickman/control.py index a6c371f0500..446697a0d5a 100644 --- a/tools/pickman/control.py +++ b/tools/pickman/control.py @@ -298,6 +298,41 @@ def do_next_merges(args, dbs): return 0 +def do_count_merges(args, dbs): + """Count total remaining merges to be applied from a source + + Args: + args (Namespace): Parsed arguments with 'source' attribute + dbs (Database): Database instance + + Returns: + int: 0 on success, 1 if source not found + """ + source = args.source + + # Get the last cherry-picked commit from database + last_commit = dbs.source_get(source) + + if not last_commit: + tout.error(f"Source '{source}' not found in database") + return 1 + + # Count merge commits on the first-parent chain + fp_output = run_git([ + 'log', '--first-parent', '--merges', '--oneline', + f'{last_commit}..{source}' + ]) + + if not fp_output: + tout.info('0 merges remaining') + return 0 + + count = len([line for line in fp_output.split('\n') if line]) + tout.info(f'{count} merges remaining from {source}') + + return 0 + + HISTORY_FILE = '.pickman-history' @@ -907,6 +942,7 @@ COMMANDS = { 'apply': do_apply, 'commit-source': do_commit_source, 'compare': do_compare, + 'count-merges': do_count_merges, 'list-sources': do_list_sources, 'next-merges': do_next_merges, 'next-set': do_next_set, diff --git a/tools/pickman/ftest.py b/tools/pickman/ftest.py index 3fbb4e20dd7..3e898eff188 100644 --- a/tools/pickman/ftest.py +++ b/tools/pickman/ftest.py @@ -1090,6 +1090,89 @@ class TestNextMerges(unittest.TestCase): self.assertIn('No merges remaining', stdout.getvalue()) +class TestCountMerges(unittest.TestCase): + """Tests for count-merges command.""" + + def setUp(self): + """Set up test fixtures.""" + fd, self.db_path = tempfile.mkstemp(suffix='.db') + os.close(fd) + os.unlink(self.db_path) + self.old_db_fname = control.DB_FNAME + control.DB_FNAME = self.db_path + database.Database.instances.clear() + + def tearDown(self): + """Clean up test fixtures.""" + control.DB_FNAME = self.old_db_fname + if os.path.exists(self.db_path): + os.unlink(self.db_path) + database.Database.instances.clear() + command.TEST_RESULT = None + + def test_count_merges(self): + """Test count-merges shows total remaining""" + # Add source to database + with terminal.capture(): + dbs = database.Database(self.db_path) + dbs.start() + dbs.source_set('us/next', 'abc123') + dbs.commit() + dbs.close() + + database.Database.instances.clear() + + # Mock git log with merge commits (oneline format) + log_output = ( + 'aaa111a Merge branch feature-1\n' + 'bbb222b Merge branch feature-2\n' + 'ccc333c Merge branch feature-3\n' + ) + command.TEST_RESULT = command.CommandResult(stdout=log_output) + + args = argparse.Namespace(cmd='count-merges', source='us/next') + with terminal.capture() as (stdout, _): + ret = control.do_pickman(args) + self.assertEqual(ret, 0) + self.assertIn('3 merges remaining from us/next', stdout.getvalue()) + + def test_count_merges_none(self): + """Test count-merges with no merges remaining""" + # Add source to database + with terminal.capture(): + dbs = database.Database(self.db_path) + dbs.start() + dbs.source_set('us/next', 'abc123') + dbs.commit() + dbs.close() + + database.Database.instances.clear() + + command.TEST_RESULT = command.CommandResult(stdout='') + + args = argparse.Namespace(cmd='count-merges', source='us/next') + with terminal.capture() as (stdout, _): + ret = control.do_pickman(args) + self.assertEqual(ret, 0) + self.assertIn('0 merges remaining', stdout.getvalue()) + + def test_count_merges_source_not_found(self): + """Test count-merges with unknown source""" + # Create empty database + with terminal.capture(): + dbs = database.Database(self.db_path) + dbs.start() + dbs.close() + + database.Database.instances.clear() + + args = argparse.Namespace(cmd='count-merges', source='unknown') + with terminal.capture() as (_, stderr): + ret = control.do_pickman(args) + self.assertEqual(ret, 1) + self.assertIn("Source 'unknown' not found", stderr.getvalue()) + + class TestGetNextCommits(unittest.TestCase): """Tests for get_next_commits function.""" -- 2.43.0
From: Simon Glass <simon.glass@canonical.com> Change poll to stop on errors rather than continuing, since errors like permission denied are not recoverable by retrying. Co-developed-by: Claude Opus 4.5 <noreply@anthropic.com> Signed-off-by: Simon Glass <simon.glass@canonical.com> --- tools/pickman/agent.py | 3 +++ tools/pickman/control.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/tools/pickman/agent.py b/tools/pickman/agent.py index e63248a1150..26f4ac3afd9 100644 --- a/tools/pickman/agent.py +++ b/tools/pickman/agent.py @@ -72,6 +72,8 @@ Steps to follow: - For regular commits: git cherry-pick -x <hash> - For merge commits (identified by "Merge" in subject): git cherry-pick -x -m 1 --allow-empty <hash> Cherry-pick one commit at a time to handle each appropriately. + IMPORTANT: Always include merge commits even if they result in empty commits. + The merge commit message is important for tracking history. 4. If there are conflicts: - Show the conflicting files - Try to resolve simple conflicts automatically @@ -91,6 +93,7 @@ Important: - Stop immediately if there's a conflict that cannot be auto-resolved - Do not force push or modify history - If cherry-pick fails, run 'git cherry-pick --abort' +- NEVER skip merge commits - always use --allow-empty to preserve them """ options = ClaudeAgentOptions( diff --git a/tools/pickman/control.py b/tools/pickman/control.py index 446697a0d5a..d9d326a0d21 100644 --- a/tools/pickman/control.py +++ b/tools/pickman/control.py @@ -907,7 +907,7 @@ def do_poll(args, dbs): try: ret = do_step(args, dbs) if ret != 0: - tout.warning(f'Step returned {ret}, continuing anyway...') + tout.warning(f'step returned {ret}') tout.info('') tout.info(f'Sleeping {interval} seconds...') time.sleep(interval) -- 2.43.0
From: Simon Glass <simon.glass@canonical.com> Add support for storing a GitLab API token in ~/.config/pickman.conf instead of an environment variable. This allows using a dedicated bot account for pickman without affecting the user's personal GitLab credentials. Config file format: [gitlab] token = glpat-xxxxxxxxxxxxxxxxxxxx This falls back to GITLAB_TOKEN environment variable if config file is not present. Co-developed-by: Claude Opus 4.5 <noreply@anthropic.com> Signed-off-by: Simon Glass <simon.glass@canonical.com> --- tools/pickman/README.rst | 17 ++++++++++--- tools/pickman/ftest.py | 49 +++++++++++++++++++++++++++++++++++++ tools/pickman/gitlab_api.py | 39 ++++++++++++++++++++++++++++- 3 files changed, 101 insertions(+), 4 deletions(-) diff --git a/tools/pickman/README.rst b/tools/pickman/README.rst index 4b99de552b2..ce520f09a45 100644 --- a/tools/pickman/README.rst +++ b/tools/pickman/README.rst @@ -192,9 +192,20 @@ To use the ``-p`` (push) option for GitLab integration, install python-gitlab:: pip install python-gitlab -You will also need a GitLab API token set in the ``GITLAB_TOKEN`` environment -variable. See `GitLab Personal Access Tokens`_ for instructions on creating one. -The token needs ``api`` scope. +You will also need a GitLab API token. The token can be configured in a config +file or environment variable. Pickman checks in this order: + +1. Config file ``~/.config/pickman.conf``:: + + [gitlab] + token = glpat-xxxxxxxxxxxxxxxxxxxx + +2. ``GITLAB_TOKEN`` environment variable +3. ``GITLAB_API_TOKEN`` environment variable + +See `GitLab Personal Access Tokens`_ for instructions on creating a token. +The token needs ``api`` scope. Using a dedicated bot account for pickman is +recommended. .. _GitLab Personal Access Tokens: https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html diff --git a/tools/pickman/ftest.py b/tools/pickman/ftest.py index 3e898eff188..66e087e6625 100644 --- a/tools/pickman/ftest.py +++ b/tools/pickman/ftest.py @@ -1431,6 +1431,55 @@ class TestCheckAvailable(unittest.TestCase): self.assertTrue(result) +class TestConfigFile(unittest.TestCase): + """Tests for config file support.""" + + def setUp(self): + """Set up test fixtures.""" + self.config_dir = tempfile.mkdtemp() + self.config_file = os.path.join(self.config_dir, 'pickman.conf') + + def tearDown(self): + """Clean up test fixtures.""" + shutil.rmtree(self.config_dir) + + def test_get_token_from_config(self): + """Test getting token from config file.""" + with open(self.config_file, 'w', encoding='utf-8') as fhandle: + fhandle.write('[gitlab]\ntoken = test-config-token\n') + + with mock.patch.object(gitlab_api, 'CONFIG_FILE', self.config_file): + token = gitlab_api.get_token() + self.assertEqual(token, 'test-config-token') + + def test_get_token_fallback_to_env(self): + """Test falling back to environment variable.""" + # Config file doesn't exist + with mock.patch.object(gitlab_api, 'CONFIG_FILE', '/nonexistent/path'): + with mock.patch.dict(os.environ, {'GITLAB_TOKEN': 'env-token'}): + token = gitlab_api.get_token() + self.assertEqual(token, 'env-token') + + def test_get_token_config_missing_section(self): + """Test config file without gitlab section.""" + with open(self.config_file, 'w', encoding='utf-8') as fhandle: + fhandle.write('[other]\nkey = value\n') + + with mock.patch.object(gitlab_api, 'CONFIG_FILE', self.config_file): + with mock.patch.dict(os.environ, {'GITLAB_TOKEN': 'env-token'}): + token = gitlab_api.get_token() + self.assertEqual(token, 'env-token') + + def test_get_config_value(self): + """Test get_config_value function.""" + with open(self.config_file, 'w', encoding='utf-8') as fhandle: + fhandle.write('[section1]\nkey1 = value1\n') + + with mock.patch.object(gitlab_api, 'CONFIG_FILE', self.config_file): + value = gitlab_api.get_config_value('section1', 'key1') + self.assertEqual(value, 'value1') + + class TestUpdateMrDescription(unittest.TestCase): """Tests for update_mr_description function.""" diff --git a/tools/pickman/gitlab_api.py b/tools/pickman/gitlab_api.py index 508168aa75c..d2297f40c93 100644 --- a/tools/pickman/gitlab_api.py +++ b/tools/pickman/gitlab_api.py @@ -6,6 +6,7 @@ """GitLab integration for pickman - push branches and create merge requests.""" from collections import namedtuple +import configparser import os import re import sys @@ -50,12 +51,48 @@ def check_available(): return True +CONFIG_FILE = os.path.expanduser('~/.config/pickman.conf') + + +def get_config_value(section, key): + """Get a value from the pickman config file + + Args: + section (str): Config section name + key (str): Config key name + + Returns: + str: Value or None if not found + """ + if not os.path.exists(CONFIG_FILE): + return None + + config = configparser.ConfigParser() + config.read(CONFIG_FILE) + + try: + return config.get(section, key) + except (configparser.NoSectionError, configparser.NoOptionError): + return None + + def get_token(): - """Get GitLab API token from environment + """Get GitLab API token from config file or environment + + Checks in order: + 1. Config file (~/.config/pickman.conf) [gitlab] token + 2. GITLAB_TOKEN environment variable + 3. GITLAB_API_TOKEN environment variable Returns: str: Token or None if not set """ + # Try config file first + token = get_config_value('gitlab', 'token') + if token: + return token + + # Fall back to environment variables return os.environ.get('GITLAB_TOKEN') or os.environ.get('GITLAB_API_TOKEN') -- 2.43.0
From: Simon Glass <simon.glass@canonical.com> Add check-gitlab command to verify GitLab permissions for the configured token. This helps diagnose issues like 403 Forbidden errors when trying to create merge requests. Co-developed-by: Claude Opus 4.5 <noreply@anthropic.com> Signed-off-by: Simon Glass <simon.glass@canonical.com> --- tools/pickman/README.rst | 7 +++ tools/pickman/__main__.py | 5 +++ tools/pickman/control.py | 40 +++++++++++++++++ tools/pickman/ftest.py | 53 ++++++++++++++++++++++ tools/pickman/gitlab_api.py | 88 +++++++++++++++++++++++++++++++++++++ 5 files changed, 193 insertions(+) diff --git a/tools/pickman/README.rst b/tools/pickman/README.rst index ce520f09a45..ff3ca3d58c3 100644 --- a/tools/pickman/README.rst +++ b/tools/pickman/README.rst @@ -60,6 +60,13 @@ This shows: master branch (ci/master) - The last common commit between the two branches +To check GitLab permissions for the configured token:: + + ./tools/pickman/pickman check-gitlab + +This verifies that the GitLab token has the required permissions to push +branches and create merge requests. Use ``-r`` to specify a different remote. + To show the next set of commits to cherry-pick from a source branch:: ./tools/pickman/pickman next-set us/next diff --git a/tools/pickman/__main__.py b/tools/pickman/__main__.py index 1258a0835c2..8a56976b872 100755 --- a/tools/pickman/__main__.py +++ b/tools/pickman/__main__.py @@ -48,6 +48,11 @@ def parse_args(argv): apply_cmd.add_argument('-t', '--target', default='master', help='Target branch for MR (default: master)') + check_gl = subparsers.add_parser('check-gitlab', + help='Check GitLab permissions') + check_gl.add_argument('-r', '--remote', default='ci', + help='Git remote (default: ci)') + commit_src = subparsers.add_parser('commit-source', help='Update database with last commit') commit_src.add_argument('source', help='Source branch name') diff --git a/tools/pickman/control.py b/tools/pickman/control.py index d9d326a0d21..b07ef703ba2 100644 --- a/tools/pickman/control.py +++ b/tools/pickman/control.py @@ -3,6 +3,7 @@ # Copyright 2025 Canonical Ltd. # Written by Simon Glass <simon.glass@canonical.com> # +# pylint: disable=too-many-lines """Control module for pickman - handles the main logic.""" from collections import namedtuple @@ -147,6 +148,44 @@ def do_compare(args, dbs): # pylint: disable=unused-argument return 0 +def do_check_gitlab(args, dbs): # pylint: disable=unused-argument + """Check GitLab permissions for the configured token + + Args: + args (Namespace): Parsed arguments with 'remote' attribute + dbs (Database): Database instance (unused) + + Returns: + int: 0 on success with sufficient permissions, 1 otherwise + """ + remote = args.remote + + perms = gitlab_api.check_permissions(remote) + if not perms: + return 1 + + tout.info(f"GitLab permission check for remote '{remote}':") + tout.info(f" Host: {perms.host}") + tout.info(f" Project: {perms.project}") + tout.info(f" User: {perms.user}") + tout.info(f" Access level: {perms.access_name}") + tout.info('') + tout.info('Permissions:') + tout.info(f" Push branches: {'Yes' if perms.can_push else 'No'}") + tout.info(f" Create MRs: {'Yes' if perms.can_create_mr else 'No'}") + tout.info(f" Merge MRs: {'Yes' if perms.can_merge else 'No'}") + + if not perms.can_create_mr: + tout.warning('') + tout.warning('Insufficient permissions to create merge requests!') + tout.warning('The user needs at least Developer access level.') + return 1 + + tout.info('') + tout.info('All required permissions are available.') + return 0 + + def get_next_commits(dbs, source): """Get the next set of commits to cherry-pick from a source @@ -940,6 +979,7 @@ def do_test(args, dbs): # pylint: disable=unused-argument COMMANDS = { 'add-source': do_add_source, 'apply': do_apply, + 'check-gitlab': do_check_gitlab, 'commit-source': do_commit_source, 'compare': do_compare, 'count-merges': do_count_merges, diff --git a/tools/pickman/ftest.py b/tools/pickman/ftest.py index 66e087e6625..769813122fb 100644 --- a/tools/pickman/ftest.py +++ b/tools/pickman/ftest.py @@ -1480,6 +1480,59 @@ class TestConfigFile(unittest.TestCase): self.assertEqual(value, 'value1') +class TestCheckPermissions(unittest.TestCase): + """Tests for check_permissions function.""" + + @mock.patch.object(gitlab_api, 'get_remote_url') + @mock.patch.object(gitlab_api, 'get_token') + @mock.patch.object(gitlab_api, 'AVAILABLE', True) + def test_check_permissions_developer(self, mock_token, mock_url): + """Test checking permissions for a developer.""" + mock_token.return_value = 'test-token' + mock_url.return_value = 'git@gitlab.com:group/project.git' + + mock_user = mock.MagicMock() + mock_user.username = 'testuser' + mock_user.id = 123 + + mock_member = mock.MagicMock() + mock_member.access_level = 30 # Developer + + mock_project = mock.MagicMock() + mock_project.members.get.return_value = mock_member + + mock_glab = mock.MagicMock() + mock_glab.user = mock_user + mock_glab.projects.get.return_value = mock_project + + with mock.patch('gitlab.Gitlab', return_value=mock_glab): + perms = gitlab_api.check_permissions('origin') + + self.assertIsNotNone(perms) + self.assertEqual(perms.user, 'testuser') + self.assertEqual(perms.access_level, 30) + self.assertEqual(perms.access_name, 'Developer') + self.assertTrue(perms.can_push) + self.assertTrue(perms.can_create_mr) + self.assertFalse(perms.can_merge) + + @mock.patch.object(gitlab_api, 'AVAILABLE', False) + def test_check_permissions_not_available(self): + """Test check_permissions when gitlab not available.""" + with terminal.capture(): + perms = gitlab_api.check_permissions('origin') + self.assertIsNone(perms) + + @mock.patch.object(gitlab_api, 'get_token') + @mock.patch.object(gitlab_api, 'AVAILABLE', True) + def test_check_permissions_no_token(self, mock_token): + """Test check_permissions when no token set.""" + mock_token.return_value = None + with terminal.capture(): + perms = gitlab_api.check_permissions('origin') + self.assertIsNone(perms) + + class TestUpdateMrDescription(unittest.TestCase): """Tests for update_mr_description function.""" diff --git a/tools/pickman/gitlab_api.py b/tools/pickman/gitlab_api.py index d2297f40c93..0db251bd9b8 100644 --- a/tools/pickman/gitlab_api.py +++ b/tools/pickman/gitlab_api.py @@ -427,3 +427,91 @@ def push_and_create_mr(remote, branch, target, title, desc=''): tout.info(f'Merge request created: {mr_url}') return mr_url + + +# Access level constants from GitLab +ACCESS_LEVELS = { + 0: 'No access', + 5: 'Minimal access', + 10: 'Guest', + 20: 'Reporter', + 30: 'Developer', + 40: 'Maintainer', + 50: 'Owner', +} + +# Permission info returned by check_permissions() +PermissionInfo = namedtuple('PermissionInfo', [ + 'user', 'user_id', 'access_level', 'access_name', + 'can_push', 'can_create_mr', 'can_merge', 'project', 'host' +]) + + +def check_permissions(remote): # pylint: disable=too-many-return-statements + """Check GitLab permissions for the current token + + Args: + remote (str): Remote name + + Returns: + PermissionInfo: Permission info, or None on failure + """ + if not check_available(): + return None + + token = get_token() + if not token: + tout.error('No GitLab token configured') + tout.error('Set token in ~/.config/pickman.conf or GITLAB_TOKEN env var') + return None + + remote_url = get_remote_url(remote) + host, proj_path = parse_url(remote_url) + + if not host or not proj_path: + tout.error(f"Could not parse GitLab URL from remote '{remote}'") + return None + + try: + glab = gitlab.Gitlab(f'https://{host}', private_token=token) + glab.auth() + user = glab.user + + project = glab.projects.get(proj_path) + + # Get user's access level in this project + access_level = 0 + try: + # Try to get the member directly + member = project.members.get(user.id) + access_level = member.access_level + except gitlab.exceptions.GitlabGetError: + # User might have inherited access from a group + try: + member = project.members_all.get(user.id) + access_level = member.access_level + except gitlab.exceptions.GitlabGetError: + pass + + access_name = ACCESS_LEVELS.get(access_level, f'Unknown ({access_level})') + + return PermissionInfo( + user=user.username, + user_id=user.id, + access_level=access_level, + access_name=access_name, + can_push=access_level >= 30, # Developer or higher + can_create_mr=access_level >= 30, # Developer or higher + can_merge=access_level >= 40, # Maintainer or higher + project=proj_path, + host=host, + ) + except gitlab.exceptions.GitlabAuthenticationError as exc: + tout.error(f'Authentication failed: {exc}') + return None + except gitlab.exceptions.GitlabGetError as exc: + tout.error(f'Could not access project: {exc}') + return None + except gitlab.exceptions.GitlabError as exc: + tout.error(f'GitLab API error: {exc}') + return None -- 2.43.0
From: Simon Glass <simon.glass@canonical.com> Remove SJG_LAB CI variable from push since lab tests are now triggered automatically for MR pipelines via .gitlab-ci.yml rules. Keep ci.skip to avoid running a pipeline on push; the MR pipeline will run when the merge request is created. Co-developed-by: Claude Opus 4.5 <noreply@anthropic.com> Signed-off-by: Simon Glass <simon.glass@canonical.com> --- tools/pickman/gitlab_api.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tools/pickman/gitlab_api.py b/tools/pickman/gitlab_api.py index 0db251bd9b8..50c3ec67909 100644 --- a/tools/pickman/gitlab_api.py +++ b/tools/pickman/gitlab_api.py @@ -146,10 +146,8 @@ def push_branch(remote, branch, force=False): bool: True on success """ try: - # Use ci.skip to avoid duplicate pipeline (MR pipeline will still run) - # Set SJG_LAB=1 CI variable for the MR pipeline - args = ['git', 'push', '-u', '-o', 'ci.skip', - '-o', 'ci.variable=SJG_LAB=1'] + # Skip push pipeline; MR pipeline will run when MR is created + args = ['git', 'push', '-u', '-o', 'ci.skip'] if force: args.append('--force-with-lease') args.extend([remote, branch]) -- 2.43.0
From: Simon Glass <simon.glass@canonical.com> Add rule to run lab tests automatically when the pipeline is triggered by a merge request event. This allows pickman-created MRs to have lab tests run without needing to set SJG_LAB=1 manually. Co-developed-by: Claude Opus 4.5 <noreply@anthropic.com> Signed-off-by: Simon Glass <simon.glass@canonical.com> --- .gitlab-ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 0d63bd4c358..ed517d123b0 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -722,6 +722,8 @@ coreboot test.py: rules: - if: $SJG_LAB == $ROLE || $SJG_LAB == "1" when: always + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + when: always - if: $SJG_LAB != "" && $SJG_LAB != "1" && $SJG_LAB != $ROLE when: never - if: $SJG_LAB == "" -- 2.43.0
From: Simon Glass <simon.glass@canonical.com> Add pickman test suite to the existing tool test job alongside binman, buildman, dtoc and patman tests. Co-developed-by: Claude Opus 4.5 <noreply@anthropic.com> Signed-off-by: Simon Glass <simon.glass@canonical.com> --- .gitlab-ci.yml | 6 ++++-- tools/pickman/requirements.txt | 4 ++++ 2 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 tools/pickman/requirements.txt diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index ed517d123b0..4d97291b484 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -273,7 +273,7 @@ Build tools-only and envtools: make mrproper; make tools-only_config envtools -j$(nproc) -Run binman, buildman, dtoc, hwids_to_dtsi, Kconfig and patman test suites: +Run binman, buildman, dtoc, hwids_to_dtsi, Kconfig, patman and pickman suites: extends: .test_suites script: - git config --global user.name "GitLab CI Runner"; @@ -284,7 +284,7 @@ Run binman, buildman, dtoc, hwids_to_dtsi, Kconfig and patman test suites: . /tmp/venv/bin/activate; pip install -r test/py/requirements.txt -r tools/binman/requirements.txt -r tools/buildman/requirements.txt -r tools/patman/requirements.txt - -r tools/u_boot_pylib/requirements.txt; + -r tools/pickman/requirements.txt -r tools/u_boot_pylib/requirements.txt; export UBOOT_TRAVIS_BUILD_DIR=/tmp/tools-only; export PYTHONPATH="${UBOOT_TRAVIS_BUILD_DIR}/scripts/dtc/pylibfdt"; export PATH="${UBOOT_TRAVIS_BUILD_DIR}/scripts/dtc:${PATH}"; @@ -299,6 +299,8 @@ Run binman, buildman, dtoc, hwids_to_dtsi, Kconfig and patman test suites: ./tools/buildman/buildman -t; ./tools/dtoc/dtoc -t; ./tools/patman/patman test; + ./tools/pickman/pickman test; + ./tools/pickman/pickman test -T; python3 ./test/scripts/test_hwids_to_dtsi.py; python3 -m pytest ./test/scripts/test_release_version.py; make testconfig diff --git a/tools/pickman/requirements.txt b/tools/pickman/requirements.txt new file mode 100644 index 00000000000..fb4a8c12692 --- /dev/null +++ b/tools/pickman/requirements.txt @@ -0,0 +1,4 @@ +# Requirements for pickman +# Install with: pip install -r tools/pickman/requirements.txt + +python-gitlab -- 2.43.0
participants (1)
-
Simon Glass