From: Simon Glass <simon.glass@canonical.com>
Add an agent which uses the Claude Agent SDK to automate cherry-picking
commits.
The agent module provides an async interface to the Claude Agent SDK
with a synchronous wrapper for easy use from the CLI.
Co-developed-by: Claude Opus 4.5 <noreply@anthropic.com>
Signed-off-by: Simon Glass <simon.glass@canonical.com>
---
tools/pickman/agent.py | 134 +++++++++++++++++++++++++++++++++++++++++
1 file changed, 134 insertions(+)
create mode 100644 tools/pickman/agent.py
new file mode 100644
@@ -0,0 +1,134 @@
+# SPDX-License-Identifier: GPL-2.0+
+#
+# Copyright 2025 Canonical Ltd.
+# Written by Simon Glass <simon.glass@canonical.com>
+#
+"""Agent module for pickman - uses Claude to automate cherry-picking."""
+
+import asyncio
+import os
+import sys
+
+# Allow 'from pickman import xxx' to work via symlink
+our_path = os.path.dirname(os.path.realpath(__file__))
+sys.path.insert(0, os.path.join(our_path, '..'))
+
+# pylint: disable=wrong-import-position,import-error
+from u_boot_pylib import tout
+
+# Check if claude_agent_sdk is available
+try:
+ from claude_agent_sdk import query, ClaudeAgentOptions
+ AGENT_AVAILABLE = True
+except ImportError:
+ AGENT_AVAILABLE = False
+
+
+def check_available():
+ """Check if the Claude Agent SDK is available
+
+ Returns:
+ bool: True if available, False otherwise
+ """
+ if not AGENT_AVAILABLE:
+ tout.error('Claude Agent SDK not available')
+ tout.error('Install with: pip install claude-agent-sdk')
+ return False
+ return True
+
+
+async def run(commits, source, branch_name, repo_path=None):
+ """Run the Claude agent to cherry-pick commits
+
+ Args:
+ commits (list): list of (hash, short_hash, subject) tuples
+ source (str): source branch name
+ branch_name (str): name for the new branch to create
+ repo_path (str): path to repository (defaults to current directory)
+
+ Returns:
+ bool: True on success, False on failure
+ """
+ if not check_available():
+ return False
+
+ if repo_path is None:
+ repo_path = os.getcwd()
+
+ # Build commit list for the prompt
+ commit_list = '\n'.join(
+ 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:
+
+{commit_list}
+
+Steps to follow:
+1. First run 'git status' to check the repository state is clean
+2. Create and checkout a new branch based on ci/master: git checkout -b {branch_name} ci/master
+3. Cherry-pick each commit in order:
+ - 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.
+4. If there are conflicts:
+ - Show the conflicting files
+ - Try to resolve simple conflicts automatically
+ - For complex conflicts, describe what needs manual resolution and abort
+ - When fix-ups are needed, amend the commit to add a one-line note at the end
+ of the commit message describing the changes made
+5. After ALL cherry-picks complete, verify with 'git log --oneline -n {len(commits) + 2}'
+ Ensure all {len(commits)} commits are present.
+6. Run 'buildman -L --board sandbox -w -o /tmp/pickman' to verify the build
+7. Report the final status including:
+ - Build result (ok or list of warnings/errors)
+ - Any fix-ups that were made
+
+The cherry-pick branch will be left ready for pushing. Do NOT merge it back to any other branch.
+
+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'
+"""
+
+ options = ClaudeAgentOptions(
+ allowed_tools=['Bash', 'Read', 'Grep'],
+ cwd=repo_path,
+ )
+
+ tout.info(f'Starting Claude agent to cherry-pick {len(commits)} commits...')
+ tout.info('')
+
+ conversation_log = []
+ try:
+ async for message in query(prompt=prompt, options=options):
+ # Print agent output and capture it
+ if hasattr(message, 'content'):
+ for block in message.content:
+ if hasattr(block, 'text'):
+ print(block.text)
+ conversation_log.append(block.text)
+ return True, '\n\n'.join(conversation_log)
+ except (RuntimeError, ValueError, OSError) as exc:
+ tout.error(f'Agent failed: {exc}')
+ return False, '\n\n'.join(conversation_log)
+
+
+def cherry_pick_commits(commits, source, branch_name, repo_path=None):
+ """Synchronous wrapper for running the cherry-pick agent
+
+ Args:
+ commits (list): list of (hash, short_hash, subject) tuples
+ source (str): source branch name
+ branch_name (str): name for the new branch to create
+ repo_path (str): path to repository (defaults to current directory)
+
+ Returns:
+ tuple: (success, conversation_log) where success is bool and
+ conversation_log is the agent's output text
+ """
+ return asyncio.run(run(commits, source, branch_name,
+ repo_path))