summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.rst2
-rw-r--r--docs/api-objects.rst1
-rw-r--r--docs/api-usage.rst22
-rw-r--r--docs/gl_objects/access_requests.rst4
-rw-r--r--docs/gl_objects/applications.rst31
-rw-r--r--docs/gl_objects/commits.rst4
-rw-r--r--docs/gl_objects/features.rst2
-rw-r--r--docs/gl_objects/projects.rst4
-rw-r--r--docs/gl_objects/users.rst27
-rw-r--r--gitlab/__init__.py13
-rw-r--r--gitlab/exceptions.py4
-rw-r--r--gitlab/mixins.py5
-rw-r--r--gitlab/tests/test_gitlab.py120
-rw-r--r--gitlab/utils.py4
-rw-r--r--gitlab/v4/objects.py70
-rwxr-xr-xtools/build_test_env.sh17
-rwxr-xr-xtools/cli_test_v4.sh13
-rw-r--r--tools/python_test_v4.py46
18 files changed, 364 insertions, 25 deletions
diff --git a/README.rst b/README.rst
index 3802bcb..c00e0c6 100644
--- a/README.rst
+++ b/README.rst
@@ -31,7 +31,7 @@ Requirements
python-gitlab depends on:
-* `python-requests <http://docs.python-requests.org/en/latest/>`_
+* `python-requests <https://2.python-requests.org/en/latest/>`_
Install with pip
----------------
diff --git a/docs/api-objects.rst b/docs/api-objects.rst
index 569435c..32f0d0c 100644
--- a/docs/api-objects.rst
+++ b/docs/api-objects.rst
@@ -7,6 +7,7 @@ API examples
gl_objects/access_requests
gl_objects/appearance
+ gl_objects/applications
gl_objects/emojis
gl_objects/badges
gl_objects/branches
diff --git a/docs/api-usage.rst b/docs/api-usage.rst
index dc88684..dac3997 100644
--- a/docs/api-usage.rst
+++ b/docs/api-usage.rst
@@ -204,6 +204,11 @@ listing methods support the ``page`` and ``per_page`` parameters:
By default GitLab does not return the complete list of items. Use the ``all``
parameter to get all the items when using listing methods:
+.. warning::
+
+ The all=True option uses keyset pagination by default if order_by is not supplied,
+ or if order_by="id".
+
.. code-block:: python
all_groups = gl.groups.list(all=True)
@@ -302,7 +307,19 @@ python-gitlab:
gl = gitlab.gitlab(url, token, api_version=4, session=session)
Reference:
-http://docs.python-requests.org/en/master/user/advanced/#proxies
+https://2.python-requests.org/en/master/user/advanced/#proxies
+
+SSL certificate verification
+----------------------------
+
+python-gitlab relies on the CA certificate bundle in the `certifi` package
+that comes with the requests library.
+
+If you need python-gitlab to use your system CA store instead, you can provide
+the path to the CA bundle in the `REQUESTS_CA_BUNDLE` environment variable.
+
+Reference:
+https://2.python-requests.org/en/master/user/advanced/#ssl-cert-verification
Client side certificate
-----------------------
@@ -319,7 +336,7 @@ The following sample illustrates how to use a client-side certificate:
gl = gitlab.gitlab(url, token, api_version=4, session=session)
Reference:
-http://docs.python-requests.org/en/master/user/advanced/#client-side-certificates
+https://2.python-requests.org/en/master/user/advanced/#client-side-certificates
Rate limits
-----------
@@ -391,4 +408,3 @@ parameter to that API invocation:
gl = gitlab.gitlab(url, token, api_version=4)
gl.projects.import_github(ACCESS_TOKEN, 123456, "root", timeout=120.0)
-
diff --git a/docs/gl_objects/access_requests.rst b/docs/gl_objects/access_requests.rst
index e890ce0..467c3e5 100644
--- a/docs/gl_objects/access_requests.rst
+++ b/docs/gl_objects/access_requests.rst
@@ -37,8 +37,8 @@ List access requests from projects and groups::
Create an access request::
- p_ar = project.accessrequests.create({})
- g_ar = group.accessrequests.create({})
+ p_ar = project.accessrequests.create()
+ g_ar = group.accessrequests.create()
Approve an access request::
diff --git a/docs/gl_objects/applications.rst b/docs/gl_objects/applications.rst
new file mode 100644
index 0000000..146b6e8
--- /dev/null
+++ b/docs/gl_objects/applications.rst
@@ -0,0 +1,31 @@
+############
+Applications
+############
+
+Reference
+---------
+
+* v4 API:
+
+ + :class:`gitlab.v4.objects.Applications`
+ + :class:`gitlab.v4.objects.ApplicationManager`
+ + :attr:`gitlab.Gitlab.applications`
+
+* GitLab API: https://docs.gitlab.com/ce/api/applications.html
+
+Examples
+--------
+
+List all OAuth applications::
+
+ applications = gl.applications.list()
+
+Create an application::
+
+ gl.applications.create({'name': 'your_app', 'redirect_uri': 'http://application.url', 'scopes': ['api']})
+
+Delete an applications::
+
+ gl.applications.delete(app_id)
+ # or
+ application.delete()
diff --git a/docs/gl_objects/commits.rst b/docs/gl_objects/commits.rst
index 97cd1c4..abfedc8 100644
--- a/docs/gl_objects/commits.rst
+++ b/docs/gl_objects/commits.rst
@@ -72,6 +72,10 @@ Cherry-pick a commit into another branch::
commit.cherry_pick(branch='target_branch')
+Revert a commit on a given branch::
+
+ commit.revert(branch='target_branch')
+
Get the references the commit has been pushed to (branches and tags)::
commit.refs() # all references
diff --git a/docs/gl_objects/features.rst b/docs/gl_objects/features.rst
index 9f5e685..2344895 100644
--- a/docs/gl_objects/features.rst
+++ b/docs/gl_objects/features.rst
@@ -24,6 +24,8 @@ Create or set a feature::
feature = gl.features.set(feature_name, True)
feature = gl.features.set(feature_name, 30)
+ feature = gl.features.set(feature_name, True, user=filipowm)
+ feature = gl.features.set(feature_name, 40, group=mygroup)
Delete a feature::
diff --git a/docs/gl_objects/projects.rst b/docs/gl_objects/projects.rst
index 8c3526c..1878757 100644
--- a/docs/gl_objects/projects.rst
+++ b/docs/gl_objects/projects.rst
@@ -103,7 +103,7 @@ Delete a project::
Fork a project::
- fork = project.forks.create({})
+ fork = project.forks.create()
# fork to a specific namespace
fork = project.forks.create({'namespace': 'myteam'})
@@ -255,7 +255,7 @@ generated by GitLab you need to:
# Create the export
p = gl.projects.get(my_project)
- export = p.exports.create({})
+ export = p.exports.create()
# Wait for the 'finished' status
export.refresh()
diff --git a/docs/gl_objects/users.rst b/docs/gl_objects/users.rst
index 3e71ac4..5b1cf3d 100644
--- a/docs/gl_objects/users.rst
+++ b/docs/gl_objects/users.rst
@@ -153,6 +153,33 @@ Revoke (delete) an impersonation token for a user::
i_t.delete()
+
+User memberships
+=========================
+
+References
+----------
+
+* v4 API:
+
+ + :class:`gitlab.v4.objects.UserMembership`
+ + :class:`gitlab.v4.objects.UserMembershipManager`
+ + :attr:`gitlab.v4.objects.User.memberships`
+
+* GitLab API: https://docs.gitlab.com/ee/api/users.html#user-memberships-admin-only
+
+List direct memberships for a user::
+
+ memberships = user.memberships.list()
+
+List only direct project memberships::
+
+ memberships = user.memberships.list(type='Project')
+
+List only direct group memberships::
+
+ memberships = user.memberships.list(type='Namespace')
+
Current User
============
diff --git a/gitlab/__init__.py b/gitlab/__init__.py
index c9716c2..f924372 100644
--- a/gitlab/__init__.py
+++ b/gitlab/__init__.py
@@ -30,7 +30,7 @@ from gitlab.exceptions import * # noqa
from gitlab import utils # noqa
__title__ = "python-gitlab"
-__version__ = "2.0.0"
+__version__ = "2.1.0"
__author__ = "Gauvain Pocentek"
__email__ = "gauvainpocentek@gmail.com"
__license__ = "LGPL3"
@@ -90,8 +90,8 @@ class Gitlab(object):
self._api_version = str(api_version)
self._server_version = self._server_revision = None
- self._base_url = url
- self._url = "%s/api/v%s" % (url, api_version)
+ self._base_url = url.rstrip("/")
+ self._url = "%s/api/v%s" % (self._base_url, api_version)
#: Timeout to use for requests to gitlab server
self.timeout = timeout
#: Headers that will be used in request to GitLab
@@ -144,6 +144,7 @@ class Gitlab(object):
self.features = objects.FeatureManager(self)
self.pagesdomains = objects.PagesDomainManager(self)
self.user_activities = objects.UserActivitiesManager(self)
+ self.applications = objects.ApplicationManager(self)
def __enter__(self):
return self
@@ -640,6 +641,12 @@ class Gitlab(object):
get_all = kwargs.pop("all", False)
url = self._build_url(path)
+ # use keyset pagination automatically, if all=True
+ order_by = kwargs.get("order_by")
+ if get_all and (not order_by or order_by == "id"):
+ kwargs["pagination"] = "keyset"
+ kwargs["order_by"] = "id"
+
if get_all is True and as_list is True:
return list(GitlabList(self, url, query_data, **kwargs))
diff --git a/gitlab/exceptions.py b/gitlab/exceptions.py
index aff3c87..d6791f2 100644
--- a/gitlab/exceptions.py
+++ b/gitlab/exceptions.py
@@ -245,6 +245,10 @@ class GitlabRepairError(GitlabOperationError):
pass
+class GitlabRevertError(GitlabOperationError):
+ pass
+
+
class GitlabLicenseError(GitlabOperationError):
pass
diff --git a/gitlab/mixins.py b/gitlab/mixins.py
index 8544499..dde11d0 100644
--- a/gitlab/mixins.py
+++ b/gitlab/mixins.py
@@ -170,7 +170,7 @@ class CreateMixin(object):
return getattr(self, "_create_attrs", (tuple(), tuple()))
@exc.on_http_error(exc.GitlabCreateError)
- def create(self, data, **kwargs):
+ def create(self, data=None, **kwargs):
"""Create a new object.
Args:
@@ -186,6 +186,9 @@ class CreateMixin(object):
GitlabAuthenticationError: If authentication is not correct
GitlabCreateError: If the server cannot perform the request
"""
+ if data is None:
+ data = {}
+
self._check_missing_create_attrs(data)
files = {}
diff --git a/gitlab/tests/test_gitlab.py b/gitlab/tests/test_gitlab.py
index 3eccf6e..249d0c5 100644
--- a/gitlab/tests/test_gitlab.py
+++ b/gitlab/tests/test_gitlab.py
@@ -376,6 +376,23 @@ class TestGitlabHttpMethods(unittest.TestCase):
self.assertRaises(GitlabHttpError, self.gl.http_delete, "/not_there")
+class TestGitlabStripBaseUrl(unittest.TestCase):
+ def setUp(self):
+ self.gl = Gitlab(
+ "http://localhost/", private_token="private_token", api_version=4
+ )
+
+ def test_strip_base_url(self):
+ self.assertEqual(self.gl.url, "http://localhost")
+
+ def test_strip_api_url(self):
+ self.assertEqual(self.gl.api_url, "http://localhost/api/v4")
+
+ def test_build_url(self):
+ r = self.gl._build_url("/projects")
+ self.assertEqual(r, "http://localhost/api/v4/projects")
+
+
class TestGitlabAuth(unittest.TestCase):
def test_invalid_auth_args(self):
self.assertRaises(
@@ -658,6 +675,38 @@ class TestGitlab(unittest.TestCase):
self.assertEqual(user.name, "name")
self.assertEqual(user.id, 1)
+ def test_user_memberships(self):
+ @urlmatch(
+ scheme="http",
+ netloc="localhost",
+ path="/api/v4/users/1/memberships",
+ method="get",
+ )
+ def resp_get_user_memberships(url, request):
+ headers = {"content-type": "application/json"}
+ content = """[
+ {
+ "source_id": 1,
+ "source_name": "Project one",
+ "source_type": "Project",
+ "access_level": "20"
+ },
+ {
+ "source_id": 3,
+ "source_name": "Group three",
+ "source_type": "Namespace",
+ "access_level": "20"
+ }
+ ]"""
+ content = content.encode("utf-8")
+ return response(200, content, headers, None, 5, request)
+
+ with HTTMock(resp_get_user_memberships):
+ user = self.gl.users.get(1, lazy=True)
+ memberships = user.memberships.list()
+ self.assertIsInstance(memberships[0], UserMembership)
+ self.assertEqual(memberships[0].source_type, "Project")
+
def test_user_status(self):
@urlmatch(
scheme="http",
@@ -794,6 +843,50 @@ class TestGitlab(unittest.TestCase):
self.gl.users.get(1, lazy=True).activate()
self.gl.users.get(1, lazy=True).deactivate()
+ def test_commit_revert(self):
+ @urlmatch(
+ scheme="http",
+ netloc="localhost",
+ path="/api/v4/projects/1/repository/commits/6b2257ea",
+ method="get",
+ )
+ def resp_get_commit(url, request):
+ headers = {"content-type": "application/json"}
+ content = """{
+ "id": "6b2257eabcec3db1f59dafbd84935e3caea04235",
+ "short_id": "6b2257ea",
+ "title": "Initial commit"
+ }"""
+ content = content.encode("utf-8")
+ return response(200, content, headers, None, 5, request)
+
+ @urlmatch(
+ scheme="http",
+ netloc="localhost",
+ path="/api/v4/projects/1/repository/commits/6b2257ea",
+ method="post",
+ )
+ def resp_revert_commit(url, request):
+ headers = {"content-type": "application/json"}
+ content = """{
+ "id": "8b090c1b79a14f2bd9e8a738f717824ff53aebad",
+ "short_id": "8b090c1b",
+ "title":"Revert \\"Initial commit\\""
+ }"""
+ content = content.encode("utf-8")
+ return response(200, content, headers, None, 5, request)
+
+ with HTTMock(resp_get_commit):
+ project = self.gl.projects.get(1, lazy=True)
+ commit = project.commits.get("6b2257ea")
+ self.assertEqual(commit.short_id, "6b2257ea")
+ self.assertEqual(commit.title, "Initial commit")
+
+ with HTTMock(resp_revert_commit):
+ revert_commit = commit.revert(branch="master")
+ self.assertEqual(revert_commit["short_id"], "8b090c1b")
+ self.assertEqual(revert_commit["title"], 'Revert "Initial commit"')
+
def test_update_submodule(self):
@urlmatch(
scheme="http", netloc="localhost", path="/api/v4/projects/1$", method="get"
@@ -871,6 +964,33 @@ class TestGitlab(unittest.TestCase):
self.assertEqual(ret["full_path"], "/".join((base_path, name)))
self.assertTrue(ret["full_name"].endswith(name))
+ def test_applications(self):
+ content = '{"name": "test_app", "redirect_uri": "http://localhost:8080", "scopes": ["api", "email"]}'
+ json_content = json.loads(content)
+
+ @urlmatch(
+ scheme="http",
+ netloc="localhost",
+ path="/api/v4/applications",
+ method="post",
+ )
+ def resp_application_create(url, request):
+ headers = {"content-type": "application/json"}
+ return response(200, json_content, headers, None, 5, request)
+
+ with HTTMock(resp_application_create):
+ application = self.gl.applications.create(
+ {
+ "name": "test_app",
+ "redirect_uri": "http://localhost:8080",
+ "scopes": ["api", "email"],
+ "confidential": False,
+ }
+ )
+ self.assertEqual(application.name, "test_app")
+ self.assertEqual(application.redirect_uri, "http://localhost:8080")
+ self.assertEqual(application.scopes, ["api", "email"])
+
def _default_config(self):
fd, temp_path = tempfile.mkstemp()
os.write(fd, valid_config)
diff --git a/gitlab/utils.py b/gitlab/utils.py
index 0992ed7..4241787 100644
--- a/gitlab/utils.py
+++ b/gitlab/utils.py
@@ -55,3 +55,7 @@ def sanitized_url(url):
parsed = urlparse(url)
new_path = parsed.path.replace(".", "%2E")
return parsed._replace(path=new_path).geturl()
+
+
+def remove_none_from_dict(data):
+ return {k: v for k, v in data.items() if v is not None}
diff --git a/gitlab/v4/objects.py b/gitlab/v4/objects.py
index f22229c..13fbb53 100644
--- a/gitlab/v4/objects.py
+++ b/gitlab/v4/objects.py
@@ -229,6 +229,17 @@ class UserImpersonationTokenManager(NoUpdateMixin, RESTManager):
_list_filters = ("state",)
+class UserMembership(RESTObject):
+ _id_attr = "source_id"
+
+
+class UserMembershipManager(RetrieveMixin, RESTManager):
+ _path = "/users/%(user_id)s/memberships"
+ _obj_cls = UserMembership
+ _from_parent_attrs = {"user_id": "id"}
+ _list_filters = ("type",)
+
+
class UserProject(RESTObject):
pass
@@ -311,6 +322,7 @@ class User(SaveMixin, ObjectDeleteMixin, RESTObject):
("gpgkeys", "UserGPGKeyManager"),
("impersonationtokens", "UserImpersonationTokenManager"),
("keys", "UserKeyManager"),
+ ("memberships", "UserMembershipManager"),
("projects", "UserProjectManager"),
("status", "UserStatusManager"),
)
@@ -414,6 +426,7 @@ class UserManager(CRUDMixin, RESTManager):
"search",
"custom_attributes",
"status",
+ "two_factor",
)
_create_attrs = (
tuple(),
@@ -438,6 +451,8 @@ class UserManager(CRUDMixin, RESTManager):
"organization",
"location",
"avatar",
+ "public_email",
+ "private_profile",
),
)
_update_attrs = (
@@ -459,6 +474,8 @@ class UserManager(CRUDMixin, RESTManager):
"organization",
"location",
"avatar",
+ "public_email",
+ "private_profile",
),
)
_types = {"confirm": types.LowercaseStringAttribute, "avatar": types.ImageAttribute}
@@ -719,7 +736,16 @@ class FeatureManager(ListMixin, DeleteMixin, RESTManager):
_obj_cls = Feature
@exc.on_http_error(exc.GitlabSetError)
- def set(self, name, value, feature_group=None, user=None, **kwargs):
+ def set(
+ self,
+ name,
+ value,
+ feature_group=None,
+ user=None,
+ group=None,
+ project=None,
+ **kwargs
+ ):
"""Create or update the object.
Args:
@@ -727,6 +753,8 @@ class FeatureManager(ListMixin, DeleteMixin, RESTManager):
value (bool/int): The value to set for the object
feature_group (str): A feature group name
user (str): A GitLab username
+ group (str): A GitLab group
+ project (str): A GitLab project in form group/project
**kwargs: Extra options to send to the server (e.g. sudo)
Raises:
@@ -737,7 +765,14 @@ class FeatureManager(ListMixin, DeleteMixin, RESTManager):
obj: The created/updated attribute
"""
path = "%s/%s" % (self.path, name.replace("/", "%2F"))
- data = {"value": value, "feature_group": feature_group, "user": user}
+ data = {
+ "value": value,
+ "feature_group": feature_group,
+ "user": user,
+ "group": group,
+ "project": project,
+ }
+ data = utils.remove_none_from_dict(data)
server_data = self.gitlab.http_post(path, post_data=data, **kwargs)
return self._obj_cls(self, server_data)
@@ -2113,6 +2148,26 @@ class ProjectCommit(RESTObject):
path = "%s/%s/merge_requests" % (self.manager.path, self.get_id())
return self.manager.gitlab.http_get(path, **kwargs)
+ @cli.register_custom_action("ProjectCommit", ("branch",))
+ @exc.on_http_error(exc.GitlabRevertError)
+ def revert(self, branch, **kwargs):
+ """Revert a commit on a given branch.
+
+ Args:
+ branch (str): Name of target branch
+ **kwargs: Extra options to send to the server (e.g. sudo)
+
+ Raises:
+ GitlabAuthenticationError: If authentication is not correct
+ GitlabRevertError: If the revert could not be performed
+
+ Returns:
+ dict: The new commit data (*not* a RESTObject)
+ """
+ path = "%s/%s/revert" % (self.manager.path, self.get_id())
+ post_data = {"branch": branch}
+ return self.manager.gitlab.http_post(path, post_data=post_data, **kwargs)
+
class ProjectCommitManager(RetrieveMixin, CreateMixin, RESTManager):
_path = "/projects/%(project_id)s/repository/commits"
@@ -5087,3 +5142,14 @@ class GeoNodeManager(RetrieveMixin, UpdateMixin, DeleteMixin, RESTManager):
list: The list of failures
"""
return self.gitlab.http_list("/geo_nodes/current/failures", **kwargs)
+
+
+class Application(ObjectDeleteMixin, RESTObject):
+ _url = "/applications"
+ _short_print_attr = "name"
+
+
+class ApplicationManager(ListMixin, CreateMixin, DeleteMixin, RESTManager):
+ _path = "/applications"
+ _obj_cls = Application
+ _create_attrs = (("name", "redirect_uri", "scopes"), ("confidential",))
diff --git a/tools/build_test_env.sh b/tools/build_test_env.sh
index f5feebf..7468a9a 100755
--- a/tools/build_test_env.sh
+++ b/tools/build_test_env.sh
@@ -29,12 +29,15 @@ REUSE_CONTAINER=
NOVENV=
PY_VER=3
API_VER=4
+GITLAB_IMAGE="gitlab/gitlab-ce"
+GITLAB_TAG="latest"
while getopts :knp:a: opt "$@"; do
case $opt in
k) REUSE_CONTAINER=1;;
n) NOVENV=1;;
p) PY_VER=$OPTARG;;
a) API_VER=$OPTARG;;
+ t) GITLAB_TAG=$OPTARG;;
:) fatal "Option -${OPTARG} requires a value";;
'?') fatal "Unknown option: -${OPTARG}";;
*) fatal "Internal error: opt=${opt}";;
@@ -81,6 +84,7 @@ cleanup() {
}
if [ -z "$REUSE_CONTAINER" ] || ! docker top gitlab-test >/dev/null 2>&1; then
+ try docker pull "$GITLAB_IMAGE:$GITLAB_TAG"
GITLAB_OMNIBUS_CONFIG="external_url 'http://gitlab.test'
gitlab_rails['initial_root_password'] = '5iveL!fe'
gitlab_rails['initial_shared_runners_registration_token'] = 'sTPNtWLEuSrHzoHP8oCU'
@@ -103,7 +107,7 @@ letsencrypt['enable'] = false
"
try docker run --name gitlab-test --detach --publish 8080:80 \
--publish 2222:22 --env "GITLAB_OMNIBUS_CONFIG=$GITLAB_OMNIBUS_CONFIG" \
- gitlab/gitlab-ce:latest >/dev/null
+ "$GITLAB_IMAGE:$GITLAB_TAG" >/dev/null
fi
LOGIN='root'
@@ -141,20 +145,13 @@ while :; do
sleep 1
docker top gitlab-test >/dev/null 2>&1 || fatal "docker failed to start"
sleep 4
- # last command started by the container is "gitlab-ctl tail"
- docker exec gitlab-test pgrep -f 'gitlab-ctl tail' &>/dev/null \
- && docker exec gitlab-test curl http://localhost/-/health 2>/dev/null \
- | grep -q 'GitLab OK' \
- && curl -s http://localhost:8080/users/sign_in 2>/dev/null \
- | grep -q "GitLab Community Edition" \
+ docker logs gitlab-test 2>&1 | grep "gitlab Reconfigured!" \
&& break
I=$((I+5))
+ log "Waiting for GitLab to reconfigure.. (${I}s)"
[ "$I" -lt 180 ] || fatal "timed out"
done
-log "Pausing to give GitLab some time to finish starting up..."
-sleep 200
-
# Get the token
TOKEN=$($(dirname $0)/generate_token.py)
diff --git a/tools/cli_test_v4.sh b/tools/cli_test_v4.sh
index dc6e0b2..cf157f4 100755
--- a/tools/cli_test_v4.sh
+++ b/tools/cli_test_v4.sh
@@ -61,6 +61,10 @@ testcase "adding member to a project" '
--user-id "$USER_ID" --access-level 40 >/dev/null 2>&1
'
+testcase "listing user memberships" '
+ GITLAB user-membership list --user-id "$USER_ID" >/dev/null 2>&1
+'
+
testcase "file creation" '
GITLAB project-file create --project-id "$PROJECT_ID" \
--file-path README --branch master --content "CONTENT" \
@@ -100,6 +104,15 @@ testcase "merge request validation" '
--iid "$MR_ID" >/dev/null 2>&1
'
+# Test revert commit
+COMMITS=$(GITLAB -v project-commit list --project-id "${PROJECT_ID}")
+COMMIT_ID=$(pecho "${COMMITS}" | grep -m1 '^id:' | cut -d' ' -f2)
+
+testcase "revert commit" '
+ GITLAB project-commit revert --project-id "$PROJECT_ID" \
+ --id "$COMMIT_ID" --branch master
+'
+
# Test project labels
testcase "create project label" '
OUTPUT=$(GITLAB -v project-label create --project-id $PROJECT_ID \
diff --git a/tools/python_test_v4.py b/tools/python_test_v4.py
index bffdd2a..90aa7f1 100644
--- a/tools/python_test_v4.py
+++ b/tools/python_test_v4.py
@@ -266,6 +266,35 @@ group1.members.create({"access_level": gitlab.const.GUEST_ACCESS, "user_id": use
group2.members.create({"access_level": gitlab.const.OWNER_ACCESS, "user_id": user2.id})
+# User memberships (admin only)
+memberships1 = user1.memberships.list()
+assert len(memberships1) == 1
+
+memberships2 = user2.memberships.list()
+assert len(memberships2) == 2
+
+membership = memberships1[0]
+assert membership.source_type == "Namespace"
+assert membership.access_level == gitlab.const.OWNER_ACCESS
+
+project_memberships = user1.memberships.list(type="Project")
+assert len(project_memberships) == 0
+
+group_memberships = user1.memberships.list(type="Namespace")
+assert len(group_memberships) == 1
+
+try:
+ membership = user1.memberships.list(type="Invalid")
+except gitlab.GitlabListError as e:
+ error_message = e.error_message
+assert error_message == "type does not have a valid value"
+
+try:
+ user1.memberships.list(sudo=user1.name)
+except gitlab.GitlabListError as e:
+ error_message = e.error_message
+assert error_message == "403 Forbidden"
+
# Administrator belongs to the groups
assert len(group1.members.list()) == 3
assert len(group2.members.list()) == 2
@@ -462,6 +491,21 @@ d_note_from_get.delete()
discussion = commit.discussions.get(discussion.id)
# assert len(discussion.attributes["notes"]) == 1
+# Revert commit
+revert_commit = commit.revert(branch="master")
+
+expected_message = 'Revert "{}"\n\nThis reverts commit {}'.format(
+ commit.message, commit.id
+)
+assert revert_commit["message"] == expected_message
+
+try:
+ commit.revert(branch="master")
+ # Only here to really ensure expected error without a full test framework
+ raise AssertionError("Two revert attempts should raise GitlabRevertError")
+except gitlab.GitlabRevertError:
+ pass
+
# housekeeping
admin_project.housekeeping()
@@ -905,7 +949,7 @@ settings.save()
[current_project.delete() for current_project in projects]
# project import/export
-ex = admin_project.exports.create({})
+ex = admin_project.exports.create()
ex.refresh()
count = 0
while ex.export_status != "finished":