Files
u-boot/tools/pickman/gitlab_api.py
Simon Glass 7ecd1216aa pickman: Fix push errors for new and aborted branches
Fix two issues with push-branch:

1. Handle missing remote branch: When pushing a new branch for the
   first time, the fetch to update tracking refs fails because the
   branch doesn't exist on the remote yet. Handle this by catching
   the fetch failure and using regular --force instead of
   --force-with-lease for new branches.

2. Detect aborted cherry-picks: When the agent aborts a cherry-pick
   (e.g., because commits are already applied), it may delete the
   branch. Verify the branch exists after the agent runs to avoid
   attempting to push a non-existent branch.

Cover-letter:
pickman and CI improvements
This series contains pickman enhancements and a CI fix:

- Process MRs oldest first to handle rebases chronologically
- Add --run-ci option for push-branch to trigger pipelines after rebase
- Fix force-push tracking ref issues by fetching before push
- Handle edge cases: new branches and agent-aborted cherry-picks
- Push master to GitHub for ReadTheDocs documentation rebuilds
END

Co-developed-by: Claude <noreply@anthropic.com>
Signed-off-by: Simon Glass <simon.glass@canonical.com>
2025-12-21 08:19:22 -07:00

650 lines
19 KiB
Python

# SPDX-License-Identifier: GPL-2.0+
#
# Copyright 2025 Canonical Ltd.
# Written by Simon Glass <simon.glass@canonical.com>
#
"""GitLab integration for pickman - push branches and create merge requests."""
from collections import namedtuple
import configparser
import os
import re
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 command
from u_boot_pylib import tout
# Check if gitlab module is available
try:
import gitlab
AVAILABLE = True
except ImportError:
AVAILABLE = False
class MrCreateError(Exception):
"""Exception for MR creation failures, used for testing
This mirrors gitlab.exceptions.GitlabCreateError so tests don't need
to import the gitlab module.
"""
def __init__(self, response_code=None, message=''):
self.response_code = response_code
super().__init__(message)
# Merge request info returned by get_pickman_mrs()
# Use defaults for new fields so existing code doesn't break
PickmanMr = namedtuple('PickmanMr', [
'iid', 'title', 'web_url', 'source_branch', 'description',
'has_conflicts', 'needs_rebase'
], defaults=[False, False])
# Comment info returned by get_mr_comments()
MrComment = namedtuple('MrComment', [
'id', 'author', 'body', 'created_at', 'resolvable', 'resolved'
])
def check_available():
"""Check if the python-gitlab module is available
Returns:
bool: True if available, False otherwise
"""
if not AVAILABLE:
tout.error('python-gitlab module not available')
tout.error('Install with: pip install python-gitlab')
return False
return True
CONFIG_FILE = os.path.expanduser('~/.config/pickman.conf')
def get_config_value(section, key):
"""Get a value from the pickman config file
Args:
section (str): Config section name
key (str): Config key name
Returns:
str: Value or None if not found
"""
if not os.path.exists(CONFIG_FILE):
return None
config = configparser.ConfigParser()
config.read(CONFIG_FILE)
try:
return config.get(section, key)
except (configparser.NoSectionError, configparser.NoOptionError):
return None
def get_token():
"""Get GitLab API token from config file or environment
Checks in order:
1. Config file (~/.config/pickman.conf) [gitlab] token
2. GITLAB_TOKEN environment variable
3. GITLAB_API_TOKEN environment variable
Returns:
str: Token or None if not set
"""
# Try config file first
token = get_config_value('gitlab', 'token')
if token:
return token
# Fall back to environment variables
return os.environ.get('GITLAB_TOKEN') or os.environ.get('GITLAB_API_TOKEN')
def get_remote_url(remote):
"""Get the URL for a git remote
Args:
remote (str): Remote name
Returns:
str: Remote URL
"""
return command.output('git', 'remote', 'get-url', remote).strip()
def parse_url(url):
"""Parse a GitLab URL to extract host and project path
Args:
url (str): Git remote URL (ssh or https)
Returns:
tuple: (host, proj_path) or (None, None) if not parseable
Examples:
- git@gitlab.com:group/project.git -> ('gitlab.com', 'group/project')
- https://gitlab.com/group/project.git -> ('gitlab.com', 'group/project')
"""
# SSH format: git@gitlab.com:group/project.git
ssh_match = re.match(r'git@([^:]+):(.+?)(?:\.git)?$', url)
if ssh_match:
return ssh_match.group(1), ssh_match.group(2)
# HTTPS format: https://gitlab.com/group/project.git
https_match = re.match(r'https?://([^/]+)/(.+?)(?:\.git)?$', url)
if https_match:
return https_match.group(1), https_match.group(2)
return None, None
def get_push_url(remote):
"""Get a push URL using the GitLab API token for authentication
This allows pushing as the token owner (e.g., a bot account) rather than
using the user's configured git credentials.
Args:
remote (str): Remote name
Returns:
str: HTTPS URL with embedded token, or None if not available
"""
token = get_token()
if not token:
return None
url = get_remote_url(remote)
host, proj_path = parse_url(url)
if not host or not proj_path:
return None
return f'https://oauth2:{token}@{host}/{proj_path}.git'
def push_branch(remote, branch, force=False, skip_ci=True):
"""Push a branch to a remote
Uses the GitLab API token for authentication if available, so the push
comes from the token owner (e.g., a bot account) rather than the user's
configured git credentials.
Args:
remote (str): Remote name
branch (str): Branch name
force (bool): Force push (overwrite remote branch)
skip_ci (bool): Skip CI pipeline (default True for new MRs where
MR pipeline runs automatically; set False for updates that
need pipeline verification)
Returns:
bool: True on success
"""
try:
# Use token-authenticated URL if available
push_url = get_push_url(remote)
push_target = push_url if push_url else remote
# When using --force-with-lease with an HTTPS URL (not remote name),
# git can't find tracking refs automatically. Try to fetch first to
# update the tracking ref. If fetch fails (branch doesn't exist on
# remote yet), use regular --force instead of --force-with-lease.
have_remote_ref = False
if force and push_url:
try:
command.output('git', 'fetch', remote, branch)
have_remote_ref = True
except command.CommandExc:
pass # Branch doesn't exist on remote, will use --force
args = ['git', 'push', '-u']
if skip_ci:
args.extend(['-o', 'ci.skip'])
if force:
if have_remote_ref:
args.append(f'--force-with-lease=refs/remotes/{remote}/{branch}')
else:
args.append('--force')
args.extend([push_target, f'HEAD:{branch}'])
command.output(*args)
return True
except command.CommandExc as exc:
tout.error(f'Failed to push branch: {exc}')
return False
# pylint: disable=too-many-arguments
def create_mr(host, proj_path, source, target, title, desc=''):
"""Create a merge request via GitLab API
Args:
host (str): GitLab host
proj_path (str): Project path (e.g., 'group/project')
source (str): Source branch name
target (str): Target branch name
title (str): MR title
desc (str): MR description
Returns:
str: MR URL on success, None on failure
"""
if not check_available():
return None
token = get_token()
if not token:
tout.error('GITLAB_TOKEN environment variable not set')
return None
try:
glab = gitlab.Gitlab(f'https://{host}', private_token=token)
project = glab.projects.get(proj_path)
merge_req = project.mergerequests.create({
'source_branch': source,
'target_branch': target,
'title': title,
'description': desc,
'remove_source_branch': False,
})
return merge_req.web_url
except (gitlab.exceptions.GitlabCreateError, MrCreateError) as exc:
# 409 means MR already exists for this source branch
if exc.response_code == 409:
mrs = project.mergerequests.list(
source_branch=source, state='opened')
if mrs:
tout.info(f'MR already exists: {mrs[0].web_url}')
return mrs[0].web_url
tout.error(f'GitLab API error: {exc}')
return None
except gitlab.exceptions.GitlabError as exc:
tout.error(f'GitLab API error: {exc}')
return None
# pylint: disable=too-many-locals
def get_pickman_mrs(remote, state='opened'):
"""Get merge requests created by pickman
Args:
remote (str): Remote name
state (str): MR state ('opened', 'merged', 'closed', 'all')
Returns:
list: List of PickmanMr tuples, or None on failure
"""
if not check_available():
return None
token = get_token()
if not token:
tout.error('GITLAB_TOKEN environment variable not set')
return None
remote_url = get_remote_url(remote)
host, proj_path = parse_url(remote_url)
if not host or not proj_path:
tout.error(f"Could not parse GitLab URL from remote '{remote}'")
return None
try:
glab = gitlab.Gitlab(f'https://{host}', private_token=token)
project = glab.projects.get(proj_path)
# Sort by created_at ascending so oldest MRs are processed first
mrs = project.mergerequests.list(state=state, order_by='created_at',
sort='asc', get_all=True)
pickman_mrs = []
for merge_req in mrs:
if '[pickman]' in merge_req.title:
needs_rebase = False
has_conflicts = False
# For open MRs, fetch full details since list() doesn't
# include accurate merge status fields
if state == 'opened':
full_mr = project.mergerequests.get(merge_req.iid)
has_conflicts = getattr(full_mr, 'has_conflicts', False)
# Check merge status - detailed_merge_status is newer API
detailed_status = getattr(full_mr,
'detailed_merge_status', '')
needs_rebase = detailed_status == 'need_rebase'
# Also check diverged_commits_count as fallback
if not needs_rebase:
diverged = getattr(full_mr, 'diverged_commits_count', 0)
needs_rebase = diverged and diverged > 0
pickman_mrs.append(PickmanMr(
iid=merge_req.iid,
title=merge_req.title,
web_url=merge_req.web_url,
source_branch=merge_req.source_branch,
description=merge_req.description or '',
has_conflicts=has_conflicts,
needs_rebase=needs_rebase,
))
return pickman_mrs
except gitlab.exceptions.GitlabError as exc:
tout.error(f'GitLab API error: {exc}')
return None
def get_open_pickman_mrs(remote):
"""Get open merge requests created by pickman
Args:
remote (str): Remote name
Returns:
list: List of dicts with 'iid', 'title', 'web_url', 'source_branch' keys,
or None on failure
"""
return get_pickman_mrs(remote, state='opened')
def get_merged_pickman_mrs(remote):
"""Get merged merge requests created by pickman
Args:
remote (str): Remote name
Returns:
list: List of dicts with 'iid', 'title', 'web_url', 'source_branch',
'description' keys, or None on failure
"""
return get_pickman_mrs(remote, state='merged')
def get_mr_comments(remote, mr_iid):
"""Get human comments on a merge request (excluding bot/system notes)
Args:
remote (str): Remote name
mr_iid (int): Merge request IID
Returns:
list: List of MrComment tuples, or None on failure
"""
if not check_available():
return None
token = get_token()
if not token:
tout.error('GITLAB_TOKEN environment variable not set')
return None
remote_url = get_remote_url(remote)
host, proj_path = parse_url(remote_url)
if not host or not proj_path:
return None
try:
glab = gitlab.Gitlab(f'https://{host}', private_token=token)
project = glab.projects.get(proj_path)
merge_req = project.mergerequests.get(mr_iid)
comments = []
for note in merge_req.notes.list(get_all=True):
# Skip system notes (merge status, etc.)
if note.system:
continue
comments.append(MrComment(
id=note.id,
author=note.author['username'],
body=note.body,
created_at=note.created_at,
resolvable=getattr(note, 'resolvable', False),
resolved=getattr(note, 'resolved', False),
))
return comments
except gitlab.exceptions.GitlabError as exc:
tout.error(f'GitLab API error: {exc}')
return None
def reply_to_mr(remote, mr_iid, message):
"""Post a reply to a merge request
Args:
remote (str): Remote name
mr_iid (int): Merge request IID
message (str): Reply message
Returns:
bool: True on success
"""
if not check_available():
return False
token = get_token()
if not token:
tout.error('GITLAB_TOKEN environment variable not set')
return False
remote_url = get_remote_url(remote)
host, proj_path = parse_url(remote_url)
if not host or not proj_path:
return False
try:
glab = gitlab.Gitlab(f'https://{host}', private_token=token)
project = glab.projects.get(proj_path)
merge_req = project.mergerequests.get(mr_iid)
merge_req.notes.create({'body': message})
return True
except gitlab.exceptions.GitlabError as exc:
tout.error(f'GitLab API error: {exc}')
return False
def update_mr_description(remote, mr_iid, desc):
"""Update a merge request's description
Args:
remote (str): Remote name
mr_iid (int): Merge request IID
desc (str): New description
Returns:
bool: True on success
"""
if not check_available():
return False
token = get_token()
if not token:
tout.error('GITLAB_TOKEN environment variable not set')
return False
remote_url = get_remote_url(remote)
host, proj_path = parse_url(remote_url)
if not host or not proj_path:
return False
try:
glab = gitlab.Gitlab(f'https://{host}', private_token=token)
project = glab.projects.get(proj_path)
merge_req = project.mergerequests.get(mr_iid)
merge_req.description = desc
merge_req.save()
return True
except gitlab.exceptions.GitlabError as exc:
tout.error(f'GitLab API error: {exc}')
return False
def update_mr_title(remote, mr_iid, title):
"""Update a merge request's title
Args:
remote (str): Remote name
mr_iid (int): Merge request IID
title (str): New title
Returns:
bool: True on success
"""
if not check_available():
return False
token = get_token()
if not token:
tout.error('GITLAB_TOKEN environment variable not set')
return False
remote_url = get_remote_url(remote)
host, proj_path = parse_url(remote_url)
if not host or not proj_path:
return False
try:
glab = gitlab.Gitlab(f'https://{host}', private_token=token)
project = glab.projects.get(proj_path)
merge_req = project.mergerequests.get(mr_iid)
merge_req.title = title
merge_req.save()
return True
except gitlab.exceptions.GitlabError as exc:
tout.error(f'GitLab API error: {exc}')
return False
def push_and_create_mr(remote, branch, target, title, desc=''):
"""Push a branch and create a merge request
Args:
remote (str): Remote name
branch (str): Branch to push
target (str): Target branch for MR
title (str): MR title
desc (str): MR description
Returns:
str: MR URL on success, None on failure
"""
# Get remote URL and parse it
remote_url = get_remote_url(remote)
host, proj_path = parse_url(remote_url)
if not host or not proj_path:
tout.error(f"Could not parse GitLab URL from remote '{remote}': "
f'{remote_url}')
return None
tout.info(f'Pushing {branch} to {remote}...')
if not push_branch(remote, branch, force=True):
return None
tout.info(f'Creating merge request to {target}...')
mr_url = create_mr(host, proj_path, branch, target, title, desc)
if mr_url:
tout.info(f'Merge request created: {mr_url}')
return mr_url
# Access level constants from GitLab
ACCESS_LEVELS = {
0: 'No access',
5: 'Minimal access',
10: 'Guest',
20: 'Reporter',
30: 'Developer',
40: 'Maintainer',
50: 'Owner',
}
# Permission info returned by check_permissions()
PermissionInfo = namedtuple('PermissionInfo', [
'user', 'user_id', 'access_level', 'access_name',
'can_push', 'can_create_mr', 'can_merge', 'project', 'host'
])
def check_permissions(remote): # pylint: disable=too-many-return-statements
"""Check GitLab permissions for the current token
Args:
remote (str): Remote name
Returns:
PermissionInfo: Permission info, or None on failure
"""
if not check_available():
return None
token = get_token()
if not token:
tout.error('No GitLab token configured')
tout.error('Set token in ~/.config/pickman.conf or GITLAB_TOKEN env var')
return None
remote_url = get_remote_url(remote)
host, proj_path = parse_url(remote_url)
if not host or not proj_path:
tout.error(f"Could not parse GitLab URL from remote '{remote}'")
return None
try:
glab = gitlab.Gitlab(f'https://{host}', private_token=token)
glab.auth()
user = glab.user
project = glab.projects.get(proj_path)
# Get user's access level in this project
access_level = 0
try:
# Try to get the member directly
member = project.members.get(user.id)
access_level = member.access_level
except gitlab.exceptions.GitlabGetError:
# User might have inherited access from a group
try:
member = project.members_all.get(user.id)
access_level = member.access_level
except gitlab.exceptions.GitlabGetError:
pass
access_name = ACCESS_LEVELS.get(access_level, f'Unknown ({access_level})')
return PermissionInfo(
user=user.username,
user_id=user.id,
access_level=access_level,
access_name=access_name,
can_push=access_level >= 30, # Developer or higher
can_create_mr=access_level >= 30, # Developer or higher
can_merge=access_level >= 40, # Maintainer or higher
project=proj_path,
host=host,
)
except gitlab.exceptions.GitlabAuthenticationError as exc:
tout.error(f'Authentication failed: {exc}')
return None
except gitlab.exceptions.GitlabGetError as exc:
tout.error(f'Could not access project: {exc}')
return None
except gitlab.exceptions.GitlabError as exc:
tout.error(f'GitLab API error: {exc}')
return None