@@ -8,6 +8,7 @@
.*
!.claude
!.checkpatch.conf
+!.pickman-history
*.a
*.asn1.[ch]
*.bin
@@ -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
--------
@@ -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')
@@ -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
@@ -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()