From: Simon Glass <simon.glass@canonical.com> Add -p/--push option to the apply command to push the cherry-pick branch to GitLab and create a merge request. Uses the python-gitlab library. Options: -p, --push Push branch and create GitLab MR -r, --remote Git remote for push (default: ci) -t, --target Target branch for MR (default: master) Requires GITLAB_TOKEN environment variable to be set. Also record cherry-pick history in .pickman-history file on successful apply. Each entry includes the date, source branch, commits, and the agent's conversation log. This file is committed automatically and included in the MR description when using -p. Name the module gitlab_api.py to avoid shadowing the python-gitlab library. Co-developed-by: Claude Opus 4.5 <noreply@anthropic.com> Signed-off-by: Simon Glass <simon.glass@canonical.com> --- .gitignore | 1 + tools/pickman/README.rst | 32 ++++++++++- tools/pickman/__main__.py | 6 +++ tools/pickman/control.py | 111 ++++++++++++++++++++++++++++++++++++-- tools/pickman/ftest.py | 24 +++++++++ 5 files changed, 169 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 986ab7ffda3..2084bb16aeb 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ .* !.claude !.checkpatch.conf +!.pickman-history *.a *.asn1.[ch] *.bin diff --git a/tools/pickman/README.rst b/tools/pickman/README.rst index 0ad634516ce..ab37763a918 100644 --- a/tools/pickman/README.rst +++ b/tools/pickman/README.rst @@ -52,7 +52,26 @@ will: - Cherry-pick each commit in order - Handle simple conflicts automatically - Report status after completion -- Update the database with the last successfully applied commit + +To push the branch and create a GitLab merge request:: + + ./tools/pickman/pickman apply us/next -p + +Options for the apply command: + +- ``-b, --branch``: Branch name to create (default: cherry-<hash>) +- ``-p, --push``: Push branch and create GitLab MR +- ``-r, --remote``: Git remote for push (default: ci) +- ``-t, --target``: Target branch for MR (default: master) + +On successful cherry-pick, an entry is appended to ``.pickman-history`` with: + +- Date and source branch +- Branch name and list of commits +- The agent's conversation log + +This file is committed automatically and included in the MR description when +using ``-p``. Requirements ------------ @@ -64,6 +83,17 @@ To use the ``apply`` command, install the Claude Agent SDK:: You will also need an Anthropic API key set in the ``ANTHROPIC_API_KEY`` environment variable. +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. + +.. _GitLab Personal Access Tokens: + https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html + Database -------- diff --git a/tools/pickman/__main__.py b/tools/pickman/__main__.py index 0ac7bfacf70..ac029a38382 100755 --- a/tools/pickman/__main__.py +++ b/tools/pickman/__main__.py @@ -38,6 +38,12 @@ def parse_args(argv): help='Apply next commits using Claude') apply_cmd.add_argument('source', help='Source branch name') apply_cmd.add_argument('-b', '--branch', help='Branch name to create') + apply_cmd.add_argument('-p', '--push', action='store_true', + help='Push branch and create GitLab MR') + apply_cmd.add_argument('-r', '--remote', default='ci', + help='Git remote for push (default: ci)') + apply_cmd.add_argument('-t', '--target', default='master', + help='Target branch for MR (default: master)') subparsers.add_parser('compare', help='Compare branches') subparsers.add_parser('list-sources', help='List tracked source branches') diff --git a/tools/pickman/control.py b/tools/pickman/control.py index 6974bbeeb7c..a482de85b00 100644 --- a/tools/pickman/control.py +++ b/tools/pickman/control.py @@ -18,6 +18,7 @@ sys.path.insert(0, os.path.join(our_path, '..')) from pickman import agent from pickman import database from pickman import ftest +from pickman import gitlab_api from u_boot_pylib import command from u_boot_pylib import tout @@ -227,7 +228,80 @@ def do_next_set(args, dbs): return 0 -def do_apply(args, dbs): +HISTORY_FILE = '.pickman-history' + + +def format_history_summary(source, commits, branch_name): + """Format a summary of the cherry-pick operation + + Args: + source (str): Source branch name + commits (list): list of CommitInfo tuples + branch_name (str): Name of the cherry-pick branch + + Returns: + str: Formatted summary text + """ + from datetime import date + + commit_list = '\n'.join( + f'- {c.short_hash} {c.subject}' + for c in commits + ) + + return f"""## {date.today()}: {source} + +Branch: {branch_name} + +Commits: +{commit_list}""" + + +def write_history(source, commits, branch_name, conversation_log): + """Write an entry to the pickman history file + + 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 + """ + import os + import re + + summary = format_history_summary(source, commits, branch_name) + entry = f"""{summary} + +### Conversation log +{conversation_log} + +--- + +""" + + # Read existing content and remove any entry for this branch + existing = '' + if os.path.exists(HISTORY_FILE): + with open(HISTORY_FILE, '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) + + # Write updated history file + with open(HISTORY_FILE, 'w', encoding='utf-8') as fhandle: + fhandle.write(existing + entry) + + # 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]) + + tout.info(f'Updated {HISTORY_FILE}') + + +def do_apply(args, dbs): # pylint: disable=too-many-locals """Apply the next set of commits using Claude agent Args: @@ -257,6 +331,14 @@ def do_apply(args, dbs): # Use first commit's short hash as part of branch name branch_name = f'cherry-{commits[0].short_hash}' + # Delete branch if it already exists + try: + run_git(['rev-parse', '--verify', branch_name]) + tout.info(f'Deleting existing branch {branch_name}') + run_git(['branch', '-D', branch_name]) + except Exception: # pylint: disable=broad-except + pass # Branch doesn't exist, which is fine + if merge_found: tout.info(f'Applying next set from {source} ({len(commits)} commits):') else: @@ -277,7 +359,8 @@ def do_apply(args, dbs): # Convert CommitInfo to tuple format expected by agent commit_tuples = [(c.hash, c.short_hash, c.subject) for c in commits] - success = agent.cherry_pick_commits(commit_tuples, source, branch_name) + success, conversation_log = agent.cherry_pick_commits(commit_tuples, source, + branch_name) # Update commit status based on result status = 'applied' if success else 'conflict' @@ -285,6 +368,10 @@ def do_apply(args, dbs): 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: @@ -292,8 +379,24 @@ def do_apply(args, dbs): run_git(['checkout', original_branch]) if success: - tout.info(f"Use 'pickman commit-source {source} {commits[-1].short_hash}' " - 'to update the database') + # Push and create MR if requested + if args.push: + remote = args.remote + target = args.target + # Use merge commit subject as title (last commit is the merge) + 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}' + + mr_url = gitlab_api.push_and_create_mr( + remote, branch_name, target, title, description + ) + if not mr_url: + return 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 diff --git a/tools/pickman/ftest.py b/tools/pickman/ftest.py index 74bf305ab96..4f5c90980c6 100644 --- a/tools/pickman/ftest.py +++ b/tools/pickman/ftest.py @@ -1016,5 +1016,29 @@ class TestCheckAvailable(unittest.TestCase): self.assertTrue(result) +class TestParseApplyWithPush(unittest.TestCase): + """Tests for apply command with push options.""" + + def test_parse_apply_with_push(self): + """Test parsing apply command with push option.""" + args = pickman.parse_args(['apply', 'us/next', '-p']) + self.assertEqual(args.cmd, 'apply') + self.assertEqual(args.source, 'us/next') + self.assertTrue(args.push) + self.assertEqual(args.remote, 'ci') + self.assertEqual(args.target, 'master') + + def test_parse_apply_with_push_options(self): + """Test parsing apply command with all push options.""" + args = pickman.parse_args([ + 'apply', 'us/next', '-p', + '-r', 'origin', '-t', 'main' + ]) + self.assertEqual(args.cmd, 'apply') + self.assertTrue(args.push) + self.assertEqual(args.remote, 'origin') + self.assertEqual(args.target, 'main') + + if __name__ == '__main__': unittest.main() -- 2.43.0