Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions bin/github-client
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ Commands:
list-repositories
get-repository-assignees
list-repository-user-permissions
get-repository-user-permission <username>
get-repository-user-permission <id> <username>
get-repository-labels
get-repository-topics
get-app-installation
Expand All @@ -80,6 +80,7 @@ import sys

from scm.manager import SourceCodeManager
from scm.types import (
Author,
CheckRunOutput,
ChmodCommitAction,
CollapsePullRequestCommentProtocol,
Expand Down Expand Up @@ -385,6 +386,7 @@ def main():
sub.add_parser("get-repository-assignees")
sub.add_parser("list-repository-user-permissions")
p = sub.add_parser("get-repository-user-permission")
p.add_argument("id")
p.add_argument("username")
sub.add_parser("get-repository-labels")
sub.add_parser("get-repository-topics")
Expand Down Expand Up @@ -656,7 +658,7 @@ def main():

elif args.command == "get-repository-user-permission":
assert isinstance(scm, GetRepositoryUserPermissionProtocol)
dump(scm.get_repository_user_permission(args.username))
dump(scm.get_repository_user_permission(Author(id=args.id, username=args.username)))

elif args.command == "get-repository-labels":
assert isinstance(scm, GetRepositoryLabelsProtocol)
Expand Down
17 changes: 17 additions & 0 deletions bin/gitlab-client
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ Commands:
[--output-title <title> --output-summary <summary> [--output-text <text>]]
get-repository
get-repository-assignees
list-repository-user-permissions
get-repository-user-permission <id> <username>
get-repository-labels
get-app-installation
"""
Expand All @@ -83,6 +85,7 @@ import sys

from scm.manager import SourceCodeManager
from scm.types import (
Author,
CheckRunOutput,
ChmodCommitAction,
CollapsePullRequestCommentProtocol,
Expand Down Expand Up @@ -122,6 +125,8 @@ from scm.types import (
GetRepositoryAssigneesProtocol,
GetRepositoryLabelsProtocol,
GetRepositoryProtocol,
GetRepositoryUserPermissionProtocol,
ListRepositoryUserPermissionsProtocol,
MoveCommitAction,
PaginationParams,
ReviewCommentInput,
Expand Down Expand Up @@ -373,6 +378,10 @@ def main():

sub.add_parser("get-repository")
sub.add_parser("get-repository-assignees")
sub.add_parser("list-repository-user-permissions")
p = sub.add_parser("get-repository-user-permission")
p.add_argument("id")
p.add_argument("username")
sub.add_parser("get-repository-labels")
sub.add_parser("get-app-installation")

Expand Down Expand Up @@ -614,6 +623,14 @@ def main():
assert isinstance(scm, GetRepositoryAssigneesProtocol)
dump(scm.get_repository_assignees())

elif args.command == "list-repository-user-permissions":
assert isinstance(scm, ListRepositoryUserPermissionsProtocol)
dump(scm.list_repository_user_permissions())

elif args.command == "get-repository-user-permission":
assert isinstance(scm, GetRepositoryUserPermissionProtocol)
dump(scm.get_repository_user_permission(Author(id=args.id, username=args.username)))

elif args.command == "get-repository-labels":
assert isinstance(scm, GetRepositoryLabelsProtocol)
dump(scm.get_repository_labels())
Expand Down
4 changes: 2 additions & 2 deletions src/scm/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,11 +164,11 @@ def list_repository_user_permissions(

def get_repository_user_permission(
scm: GetRepositoryUserPermissionProtocol,
username: str,
author: Author,
request_options: RequestOptions | None = None,
) -> ActionResult[UserPermissions]:
"""Get repository permissions for a single user."""
return scm.get_repository_user_permission(username, request_options)
return scm.get_repository_user_permission(author, request_options)


def get_repository_labels(
Expand Down
14 changes: 8 additions & 6 deletions src/scm/providers/github/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -518,18 +518,20 @@ def list_repository_user_permissions(
pagination=pagination,
request_options=request_options,
)
return map_paginated_action(pagination, response, lambda r: [map_collaborator_user_perms(user) for user in r])
return map_paginated_action(
pagination, response, lambda r: [map_collaborator_permissions_dict(user) for user in r]
)

def get_repository_user_permission(
self,
username: str,
author: Author,
request_options: RequestOptions | None = None,
) -> ActionResult[UserPermissions]:
response = self.get(
f"/repos/{self.repository['name']}/collaborators/{username}/permission",
f"/repos/{self.repository['name']}/collaborators/{author['username']}/permission",
request_options=request_options,
)
return map_action(response, map_collaborator_permission_user_perms)
return map_action(response, map_collaborator_permission)

def get_repository_labels(
self,
Expand Down Expand Up @@ -1760,7 +1762,7 @@ def map_github_repository_permission(permissions: dict[str, bool]) -> Repository
return "none"


def map_collaborator_user_perms(raw: dict[str, Any]) -> UserPermissions:
def map_collaborator_permissions_dict(raw: dict[str, Any]) -> UserPermissions:
return UserPermissions(
login=raw["login"],
id=str(raw["id"]),
Expand All @@ -1780,7 +1782,7 @@ def map_collaborator_permission_level(permission: str) -> RepositoryPermission:
raise ValueError(f"unmappable repository permission: {permission!r}")


def map_collaborator_permission_user_perms(raw: dict[str, Any]) -> UserPermissions:
def map_collaborator_permission(raw: dict[str, Any]) -> UserPermissions:
user = raw["user"]
return UserPermissions(
login=user["login"],
Expand Down
50 changes: 50 additions & 0 deletions src/scm/providers/gitlab/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
ReactionResult,
Referrer,
Repository,
RepositoryPermission,
RequestOptions,
ResourceId,
Review,
Expand All @@ -74,6 +75,7 @@
ReviewThread,
ReviewThreadComment,
TreeEntry,
UserPermissions,
WriteCommitAction,
)

Expand All @@ -100,6 +102,8 @@ class GitLab:
issue = "/projects/{project}/issues/{issue}"
issues = "/projects/{project}/issues"
project_users = "/projects/{project_id}/users"
project_members = "/projects/{project_id}/members/all"
project_member = "/projects/{project_id}/members/all/{user_id}"
project_labels = "/projects/{project_id}/labels"
issue_awards = "/projects/{project_id}/issues/{issue_id}/award_emoji"
issue_award = "/projects/{project_id}/issues/{issue_id}/award_emoji/{award_id}"
Expand Down Expand Up @@ -342,6 +346,29 @@ def get_repository_assignees(
)
return make_paginated_result(map_author, response, response.json())

def list_repository_user_permissions(
self,
pagination: PaginationParams | None = None,
request_options: RequestOptions | None = None,
) -> PaginatedActionResult[list[UserPermissions]]:
response = self.get(
GitLab.project_members.format(project_id=self.project_id),
pagination=pagination,
request_options=request_options,
)
return make_paginated_result(map_member_permissions, response, response.json())

def get_repository_user_permission(
self,
author: Author,
request_options: RequestOptions | None = None,
) -> ActionResult[UserPermissions]:
response = self.get(
GitLab.project_member.format(project_id=self.project_id, user_id=author["id"]),
request_options=request_options,
)
return make_result(map_member_permissions, response.json())

def get_repository_labels(
self,
pagination: PaginationParams | None = None,
Expand Down Expand Up @@ -1820,6 +1847,29 @@ def map_app_installation(raw: dict[str, Any]) -> AppInstallation:
)


def map_access_level(access_level: int) -> RepositoryPermission:
# GitLab default roles, keyed by numerical access level:
# https://docs.gitlab.com/user/permissions/#default-roles
# Maintainer (40) and Owner (50) can administer the project; Developer (30)
# can push; Reporter (20) and Guest (10) are read-only; anything lower has
# no access.
if access_level >= 40: # Maintainer, Owner
return "admin"
if access_level >= 30: # Developer
return "write"
if access_level >= 10: # Guest, Planner, Reporter
return "read"
return "none"


def map_member_permissions(raw: dict[str, Any]) -> UserPermissions:
return UserPermissions(
login=raw["username"],
id=str(raw["id"]),
perms=map_access_level(raw["access_level"]),
)


def map_repository(raw: dict[str, Any]) -> GitRepository:
statistics = raw.get("statistics")
repo_size = statistics.get("repository_size", 0) if statistics else 0
Expand Down
4 changes: 2 additions & 2 deletions src/scm/test_fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -712,11 +712,11 @@ def list_repository_user_permissions(

def get_repository_user_permission(
self,
username: str,
author: Author,
request_options: RequestOptions | None = None,
) -> ActionResult[UserPermissions]:
return ActionResult(
data=UserPermissions(login=username, id="123", perms="write"),
data=UserPermissions(login=author["username"], id=author["id"], perms="write"),
type="github",
raw={"headers": None, "data": None},
meta={},
Expand Down
2 changes: 1 addition & 1 deletion src/scm/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -644,7 +644,7 @@ def list_repository_user_permissions(
class GetRepositoryUserPermissionProtocol(Protocol):
def get_repository_user_permission(
self,
username: str,
author: Author,
request_options: RequestOptions | None = None,
) -> ActionResult[UserPermissions]: ...

Expand Down
2 changes: 1 addition & 1 deletion tests/unit/provider/test_github.py
Original file line number Diff line number Diff line change
Expand Up @@ -722,7 +722,7 @@ def expected_check_run(raw: dict[str, Any]) -> dict[str, Any]:
{
"name": "get_repository_user_permission",
"operation": "get",
"kwargs": {"username": "testuser"},
"kwargs": {"author": {"id": "123", "username": "testuser"}},
"path": "/repos/test-org/test-repo/collaborators/testuser/permission",
"raw": make_collaborator_permission(permission="write"),
"expected_data": {"login": "testuser", "id": "123", "perms": "write"},
Expand Down
79 changes: 79 additions & 0 deletions tests/unit/provider/test_gitlab.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
GitLabProvider,
_count_unified_diff_changes,
_head_to_source_branch,
map_access_level,
map_app_installation,
map_pull_request_file,
)
Expand Down Expand Up @@ -165,6 +166,68 @@ def _make_mock_response(json_data):
"meta": {"next_cursor": None},
},
),
ForwardToClientTest(
provider_method=GitLabProvider.list_repository_user_permissions,
provider_args={"pagination": None, "request_options": None},
client_calls=[
ClientForwardedCall(
method="GET",
path="/projects/79787061/members/all",
json_response=[
{"id": 1, "username": "dev", "name": "Dev", "state": "active", "access_level": 30},
{"id": 2, "username": "owner", "name": "Owner", "state": "active", "access_level": 50},
],
),
],
provider_return_value={
"data": [
{"login": "dev", "id": "1", "perms": "write"},
{"login": "owner", "id": "2", "perms": "admin"},
],
"type": "gitlab",
"raw": {
"data": [
{"id": 1, "username": "dev", "name": "Dev", "state": "active", "access_level": 30},
{"id": 2, "username": "owner", "name": "Owner", "state": "active", "access_level": 50},
],
"headers": None,
},
"meta": {"next_cursor": None},
},
),
ForwardToClientTest(
provider_method=GitLabProvider.get_repository_user_permission,
# GitLab resolves the member by Author.id, not username.
provider_args={"author": {"id": "42", "username": "maintainer"}, "request_options": None},
client_calls=[
ClientForwardedCall(
method="GET",
path="/projects/79787061/members/all/42",
json_response={
"id": 42,
"username": "maintainer",
"name": "Maintainer",
"state": "active",
"access_level": 40,
},
),
],
provider_return_value={
"data": {"login": "maintainer", "id": "42", "perms": "admin"},
"type": "gitlab",
"raw": {
"data": {
"id": 42,
"username": "maintainer",
"name": "Maintainer",
"state": "active",
"access_level": 40,
},
"headers": None,
},
"meta": {},
},
),
ForwardToClientTest(
provider_method=GitLabProvider.get_repository_topics,
provider_args={"request_options": None},
Expand Down Expand Up @@ -14219,3 +14282,19 @@ def test_request_maps_status_code_to_error(

assert exc_info.value.code == expected_code
assert exc_info.value.detail == '{"message":"upstream said no"}'


@pytest.mark.parametrize(
("access_level", "expected"),
[
(0, "none"), # No access
(5, "none"), # Minimal access
(10, "read"), # Guest
(20, "read"), # Reporter
(30, "write"), # Developer
(40, "admin"), # Maintainer
(50, "admin"), # Owner
],
)
def test_map_access_level(access_level: int, expected: str) -> None:
assert map_access_level(access_level) == expected
2 changes: 1 addition & 1 deletion tests/unit/test_rpc_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ def make_client_scm(organization_id, repository_id, server):
),
(
"get_repository_user_permission",
lambda scm: actions.get_repository_user_permission(scm, "reader"),
lambda scm: actions.get_repository_user_permission(scm, {"id": "1", "username": "reader"}),
{"permission": "read", "role_name": "read", "user": {"login": "reader", "id": 1}},
200,
None,
Expand Down
Loading