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