pickman: Add GitLab integration to push and create MRs

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>
This commit is contained in:
Simon Glass
2025-12-15 11:24:00 -07:00
committed by Simon Glass
parent 616e0abb7b
commit b03663be7b
5 changed files with 169 additions and 5 deletions

1
.gitignore vendored
View File

@@ -8,6 +8,7 @@
.*
!.claude
!.checkpatch.conf
!.pickman-history
*.a
*.asn1.[ch]
*.bin

View File

@@ -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
--------

View File

@@ -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')

View File

@@ -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

View File

@@ -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()