From patchwork Wed Dec 17 02:28:10 2025 Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: 7bit X-Patchwork-Submitter: Simon Glass X-Patchwork-Id: 964 Return-Path: X-Original-To: u-boot-concept@u-boot.org Delivered-To: u-boot-concept@u-boot.org DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=u-boot.org; s=default; t=1765938618; bh=/y4UPesyDSLV76G+PUcMcw8Pn0TSBYZz6fobrtbp7eM=; h=From:To:Date:In-Reply-To:References:CC:Subject:List-Id: List-Archive:List-Help:List-Owner:List-Post:List-Subscribe: List-Unsubscribe:From; b=AK6Lt5oMs9+VbLplSNray2eLu4TbvM/kwtM74Fb+PhvYtnPWB3HpaHj3BF0BV9RDy hurW8cdSCf3AwFzp90jKI0l6cIReeNyZuhSLVQP85XmvbHyNp/UlcZp25rgmfFM920 jrkVJ4+wRIpkNe8xrXAnC3ZjKTsFksoWu2FvzdbMlYER9qhSPqmFJEf+cFM1FVzsr4 SLGPMPHcDWQS3CBqX4rq1+GCKko00S2Chu8uiVkOAgsWSEN6Hi30W3uOJnBtwmXtqU M3XStY+LlmXGWpLm7StLrC5K9kqXsFM9gn6fDaIqyINnbUFRtViy+omk0/aVXI/cF7 QyN6Qj16ZAsxA== Received: from localhost (localhost [127.0.0.1]) by mail.u-boot.org (Postfix) with ESMTP id 461A668BD0 for ; Tue, 16 Dec 2025 19:30:18 -0700 (MST) X-Virus-Scanned: Debian amavis at Received: from mail.u-boot.org ([127.0.0.1]) by localhost (mail.u-boot.org [127.0.0.1]) (amavis, port 10024) with ESMTP id kyCgze2tb_rz for ; Tue, 16 Dec 2025 19:30:18 -0700 (MST) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=u-boot.org; s=default; t=1765938618; bh=/y4UPesyDSLV76G+PUcMcw8Pn0TSBYZz6fobrtbp7eM=; h=From:To:Date:In-Reply-To:References:CC:Subject:List-Id: List-Archive:List-Help:List-Owner:List-Post:List-Subscribe: List-Unsubscribe:From; b=AK6Lt5oMs9+VbLplSNray2eLu4TbvM/kwtM74Fb+PhvYtnPWB3HpaHj3BF0BV9RDy hurW8cdSCf3AwFzp90jKI0l6cIReeNyZuhSLVQP85XmvbHyNp/UlcZp25rgmfFM920 jrkVJ4+wRIpkNe8xrXAnC3ZjKTsFksoWu2FvzdbMlYER9qhSPqmFJEf+cFM1FVzsr4 SLGPMPHcDWQS3CBqX4rq1+GCKko00S2Chu8uiVkOAgsWSEN6Hi30W3uOJnBtwmXtqU M3XStY+LlmXGWpLm7StLrC5K9kqXsFM9gn6fDaIqyINnbUFRtViy+omk0/aVXI/cF7 QyN6Qj16ZAsxA== Received: from mail.u-boot.org (localhost [127.0.0.1]) by mail.u-boot.org (Postfix) with ESMTP id 30ED168BA8 for ; Tue, 16 Dec 2025 19:30:18 -0700 (MST) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=u-boot.org; s=default; t=1765938617; bh=6YsO/qRJ+3F/MOfZIQWKAz/mhlUGprylD2JVLUoevj0=; h=From:To:Cc:Subject:Date:In-Reply-To:References:From; b=SY/ZHqxVpLBYQdAT5KFooW/kRKJKbHqiL6GTNfHP1IDbNOSB0Qnq8ArsfbmiTVn+4 +mAUqJgmyYVmd2QqC9YJqL48CMxeGON5Fx6RlNylfxktYDTxbwetWvpsj8+t2yddS1 bIULxiihGLWSjZeLPgJZjdCJvQtmY1ZHjOehPb9JfUH9ke5sVJKJ9QTLyzID4i0JXK NLZH4aaR6kpDl9qpES5y6YtPtTLq8B2lTmlqiKS939dSYhNXPjT8VND67vw3W76yRT sOvGmAN2vXfQhNrPcBRIR/i2uCqKg90ZGVi3eu9QR+YtUeMoUmNYfy21vP+cVT+2hZ DifP455snBgQw== Received: from localhost (localhost [127.0.0.1]) by mail.u-boot.org (Postfix) with ESMTP id 1722468BA8; Tue, 16 Dec 2025 19:30:17 -0700 (MST) X-Virus-Scanned: Debian amavis at Received: from mail.u-boot.org ([127.0.0.1]) by localhost (mail.u-boot.org [127.0.0.1]) (amavis, port 10026) with ESMTP id iImrkbzZk0Ew; Tue, 16 Dec 2025 19:30:17 -0700 (MST) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=u-boot.org; s=default; t=1765938612; bh=aqe1IQLyrCMpjAIb5eHhTIqa+1RhqjmjYg9h199kenQ=; h=From:To:Cc:Subject:Date:In-Reply-To:References:From; b=lYQV5rEv5FYc9EdzAdJcdb23aseyBdFAlrzjxzblkPIGkLL3sq9wgTlmxmyviLUKf VGvEVzwEbbSOA1VHbkBCWdrAAXhJCV+HnabrBFyuWyXW5m56b+bhIjdWnVaAe7vgv/ ZiEqyTfvKIUATRkZwaNfnpG5Z9OCKi692Gd8F3HpkLmz+P5+M/kL1ANd/FUjLx1IyD KrDewzLRLx/InfcOY1zPQ7dHCPsWJyBfQyGqzKzY9yRUIYRUV4Q4LyJTuYunNRB8us 4+mh5i2U0iCkvjHg4mLMWxcIVcpPRY1RAoZAerH0Z8MxsYoqJ53tAFdUUOuG4P6YEU 4Se4nrx/vMQBQ== Received: from u-boot.org (unknown [73.34.74.121]) by mail.u-boot.org (Postfix) with ESMTPSA id 970286884F; Tue, 16 Dec 2025 19:30:12 -0700 (MST) From: Simon Glass To: U-Boot Concept Date: Tue, 16 Dec 2025 19:28:10 -0700 Message-ID: <20251217022823.392557-22-sjg@u-boot.org> X-Mailer: git-send-email 2.43.0 In-Reply-To: <20251217022823.392557-1-sjg@u-boot.org> References: <20251217022823.392557-1-sjg@u-boot.org> MIME-Version: 1.0 Message-ID-Hash: N5ZGXFJYYR7LHU6XE3A4IY3TTC5HVMDQ X-Message-ID-Hash: N5ZGXFJYYR7LHU6XE3A4IY3TTC5HVMDQ X-MailFrom: sjg@u-boot.org X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header CC: Simon Glass , "Claude Opus 4 . 5" X-Mailman-Version: 3.3.10 Precedence: list Subject: [Concept] [PATCH 21/24] pickman: Add check-gitlab command List-Id: Discussion and patches related to U-Boot Concept Archived-At: List-Archive: List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: From: Simon Glass Add check-gitlab command to verify GitLab permissions for the configured token. This helps diagnose issues like 403 Forbidden errors when trying to create merge requests. Co-developed-by: Claude Opus 4.5 Signed-off-by: Simon Glass --- tools/pickman/README.rst | 7 +++ tools/pickman/__main__.py | 5 +++ tools/pickman/control.py | 40 +++++++++++++++++ tools/pickman/ftest.py | 53 ++++++++++++++++++++++ tools/pickman/gitlab_api.py | 88 +++++++++++++++++++++++++++++++++++++ 5 files changed, 193 insertions(+) diff --git a/tools/pickman/README.rst b/tools/pickman/README.rst index ce520f09a45..ff3ca3d58c3 100644 --- a/tools/pickman/README.rst +++ b/tools/pickman/README.rst @@ -60,6 +60,13 @@ This shows: master branch (ci/master) - The last common commit between the two branches +To check GitLab permissions for the configured token:: + + ./tools/pickman/pickman check-gitlab + +This verifies that the GitLab token has the required permissions to push +branches and create merge requests. Use ``-r`` to specify a different remote. + To show the next set of commits to cherry-pick from a source branch:: ./tools/pickman/pickman next-set us/next diff --git a/tools/pickman/__main__.py b/tools/pickman/__main__.py index 1258a0835c2..8a56976b872 100755 --- a/tools/pickman/__main__.py +++ b/tools/pickman/__main__.py @@ -48,6 +48,11 @@ def parse_args(argv): apply_cmd.add_argument('-t', '--target', default='master', help='Target branch for MR (default: master)') + check_gl = subparsers.add_parser('check-gitlab', + help='Check GitLab permissions') + check_gl.add_argument('-r', '--remote', default='ci', + help='Git remote (default: ci)') + commit_src = subparsers.add_parser('commit-source', help='Update database with last commit') commit_src.add_argument('source', help='Source branch name') diff --git a/tools/pickman/control.py b/tools/pickman/control.py index d9d326a0d21..b07ef703ba2 100644 --- a/tools/pickman/control.py +++ b/tools/pickman/control.py @@ -3,6 +3,7 @@ # Copyright 2025 Canonical Ltd. # Written by Simon Glass # +# pylint: disable=too-many-lines """Control module for pickman - handles the main logic.""" from collections import namedtuple @@ -147,6 +148,44 @@ def do_compare(args, dbs): # pylint: disable=unused-argument return 0 +def do_check_gitlab(args, dbs): # pylint: disable=unused-argument + """Check GitLab permissions for the configured token + + Args: + args (Namespace): Parsed arguments with 'remote' attribute + dbs (Database): Database instance (unused) + + Returns: + int: 0 on success with sufficient permissions, 1 otherwise + """ + remote = args.remote + + perms = gitlab_api.check_permissions(remote) + if not perms: + return 1 + + tout.info(f"GitLab permission check for remote '{remote}':") + tout.info(f" Host: {perms.host}") + tout.info(f" Project: {perms.project}") + tout.info(f" User: {perms.user}") + tout.info(f" Access level: {perms.access_name}") + tout.info('') + tout.info('Permissions:') + tout.info(f" Push branches: {'Yes' if perms.can_push else 'No'}") + tout.info(f" Create MRs: {'Yes' if perms.can_create_mr else 'No'}") + tout.info(f" Merge MRs: {'Yes' if perms.can_merge else 'No'}") + + if not perms.can_create_mr: + tout.warning('') + tout.warning('Insufficient permissions to create merge requests!') + tout.warning('The user needs at least Developer access level.') + return 1 + + tout.info('') + tout.info('All required permissions are available.') + return 0 + + def get_next_commits(dbs, source): """Get the next set of commits to cherry-pick from a source @@ -940,6 +979,7 @@ def do_test(args, dbs): # pylint: disable=unused-argument COMMANDS = { 'add-source': do_add_source, 'apply': do_apply, + 'check-gitlab': do_check_gitlab, 'commit-source': do_commit_source, 'compare': do_compare, 'count-merges': do_count_merges, diff --git a/tools/pickman/ftest.py b/tools/pickman/ftest.py index 66e087e6625..769813122fb 100644 --- a/tools/pickman/ftest.py +++ b/tools/pickman/ftest.py @@ -1480,6 +1480,59 @@ class TestConfigFile(unittest.TestCase): self.assertEqual(value, 'value1') +class TestCheckPermissions(unittest.TestCase): + """Tests for check_permissions function.""" + + @mock.patch.object(gitlab_api, 'get_remote_url') + @mock.patch.object(gitlab_api, 'get_token') + @mock.patch.object(gitlab_api, 'AVAILABLE', True) + def test_check_permissions_developer(self, mock_token, mock_url): + """Test checking permissions for a developer.""" + mock_token.return_value = 'test-token' + mock_url.return_value = 'git@gitlab.com:group/project.git' + + mock_user = mock.MagicMock() + mock_user.username = 'testuser' + mock_user.id = 123 + + mock_member = mock.MagicMock() + mock_member.access_level = 30 # Developer + + mock_project = mock.MagicMock() + mock_project.members.get.return_value = mock_member + + mock_glab = mock.MagicMock() + mock_glab.user = mock_user + mock_glab.projects.get.return_value = mock_project + + with mock.patch('gitlab.Gitlab', return_value=mock_glab): + perms = gitlab_api.check_permissions('origin') + + self.assertIsNotNone(perms) + self.assertEqual(perms.user, 'testuser') + self.assertEqual(perms.access_level, 30) + self.assertEqual(perms.access_name, 'Developer') + self.assertTrue(perms.can_push) + self.assertTrue(perms.can_create_mr) + self.assertFalse(perms.can_merge) + + @mock.patch.object(gitlab_api, 'AVAILABLE', False) + def test_check_permissions_not_available(self): + """Test check_permissions when gitlab not available.""" + with terminal.capture(): + perms = gitlab_api.check_permissions('origin') + self.assertIsNone(perms) + + @mock.patch.object(gitlab_api, 'get_token') + @mock.patch.object(gitlab_api, 'AVAILABLE', True) + def test_check_permissions_no_token(self, mock_token): + """Test check_permissions when no token set.""" + mock_token.return_value = None + with terminal.capture(): + perms = gitlab_api.check_permissions('origin') + self.assertIsNone(perms) + + class TestUpdateMrDescription(unittest.TestCase): """Tests for update_mr_description function.""" diff --git a/tools/pickman/gitlab_api.py b/tools/pickman/gitlab_api.py index d2297f40c93..0db251bd9b8 100644 --- a/tools/pickman/gitlab_api.py +++ b/tools/pickman/gitlab_api.py @@ -427,3 +427,91 @@ def push_and_create_mr(remote, branch, target, title, desc=''): 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