[Concept,08/17] pickman: Add a Claude agent

Message ID 20251217022611.389379-9-sjg@u-boot.org
State New
Headers
Series pickman: Add a manager for cherry-picks |

Commit Message

Simon Glass Dec. 17, 2025, 2:25 a.m. UTC
  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
  

Patch

diff --git a/tools/pickman/agent.py b/tools/pickman/agent.py
new file mode 100644
index 00000000000..3b4b96838b7
--- /dev/null
+++ b/tools/pickman/agent.py
@@ -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))