summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/api-objects.rst1
-rw-r--r--docs/cli.rst13
-rw-r--r--docs/gl_objects/deploy_tokens.rst137
-rw-r--r--docs/gl_objects/groups.rst56
-rw-r--r--docs/gl_objects/remote_mirrors.rst34
-rw-r--r--gitlab/__init__.py1
-rw-r--r--gitlab/exceptions.py4
-rw-r--r--gitlab/mixins.py29
-rw-r--r--gitlab/tests/objects/mocks.py35
-rw-r--r--gitlab/tests/objects/test_commits.py3
-rw-r--r--gitlab/tests/objects/test_groups.py101
-rw-r--r--gitlab/tests/objects/test_projects.py123
-rw-r--r--gitlab/tests/test_gitlab.py51
-rw-r--r--gitlab/tests/test_types.py10
-rw-r--r--gitlab/types.py4
-rw-r--r--gitlab/v4/objects.py156
-rwxr-xr-xtools/cli_test_v4.sh88
-rw-r--r--tools/python_test_v4.py103
18 files changed, 864 insertions, 85 deletions
diff --git a/docs/api-objects.rst b/docs/api-objects.rst
index 32f0d0c..32852f8 100644
--- a/docs/api-objects.rst
+++ b/docs/api-objects.rst
@@ -37,6 +37,7 @@ API examples
gl_objects/projects
gl_objects/protected_branches
gl_objects/runners
+ gl_objects/remote_mirrors
gl_objects/repositories
gl_objects/repository_tags
gl_objects/search
diff --git a/docs/cli.rst b/docs/cli.rst
index b4a6c5e..b5c8e52 100644
--- a/docs/cli.rst
+++ b/docs/cli.rst
@@ -207,6 +207,19 @@ Get a specific user by id:
$ gitlab user get --id 3
+Create a deploy token for a project:
+
+.. code-block:: console
+
+ $ gitlab -v project-deploy-token create --project-id 2 \
+ --name bar --username root --expires-at "2021-09-09" --scopes "read_repository"
+
+List deploy tokens for a group:
+
+.. code-block:: console
+
+ $ gitlab -v group-deploy-token list --group-id 3
+
Get a list of snippets for this project:
.. code-block:: console
diff --git a/docs/gl_objects/deploy_tokens.rst b/docs/gl_objects/deploy_tokens.rst
new file mode 100644
index 0000000..404bf09
--- /dev/null
+++ b/docs/gl_objects/deploy_tokens.rst
@@ -0,0 +1,137 @@
+#######
+Deploy tokens
+#######
+
+Deploy tokens allow read-only access to your repository and registry images
+without having a user and a password.
+
+Deploy tokens
+=============
+
+This endpoint requires admin access.
+
+Reference
+---------
+
+* v4 API:
+
+ + :class:`gitlab.v4.objects.DeployToken`
+ + :class:`gitlab.v4.objects.DeployTokenManager`
+ + :attr:`gitlab.Gitlab.deploytokens`
+
+* GitLab API: https://docs.gitlab.com/ce/api/deploy_tokens.html
+
+Examples
+--------
+
+Use the ``list()`` method to list all deploy tokens across the GitLab instance.
+
+::
+
+ # List deploy tokens
+ deploy_tokens = gl.deploytokens.list()
+
+Project deploy tokens
+=====================
+
+This endpoint requires project maintainer access or higher.
+
+Reference
+---------
+
+* v4 API:
+
+ + :class:`gitlab.v4.objects.ProjectDeployToken`
+ + :class:`gitlab.v4.objects.ProjectDeployTokenManager`
+ + :attr:`gitlab.v4.objects.Project.deploytokens`
+
+* GitLab API: https://docs.gitlab.com/ce/api/deploy_tokens.html#project-deploy-tokens
+
+Examples
+--------
+
+List the deploy tokens for a project::
+
+ deploy_tokens = project.deploytokens.list()
+
+Create a new deploy token to access registry images of a project:
+
+In addition to required parameters ``name`` and ``scopes``, this method accepts
+the following parameters:
+
+* ``expires_at`` Expiration date of the deploy token. Does not expire if no value is provided.
+* ``username`` Username for deploy token. Default is ``gitlab+deploy-token-{n}``
+
+
+::
+
+ deploy_token = project.deploytokens.create({'name': 'token1', 'scopes': ['read_registry'], 'username':'', 'expires_at':''})
+ # show its id
+ print(deploy_token.id)
+ # show the token value. Make sure you save it, you won't be able to access it again.
+ print(deploy_token.token)
+
+.. warning::
+
+ With GitLab 12.9, even though ``username`` and ``expires_at`` are not required, they always have to be passed to the API.
+ You can set them to empty strings, see: https://gitlab.com/gitlab-org/gitlab/-/issues/211878.
+ Also, the ``username``'s value is ignored by the API and will be overriden with ``gitlab+deploy-token-{n}``,
+ see: https://gitlab.com/gitlab-org/gitlab/-/issues/211963
+ These issues were fixed in GitLab 12.10.
+
+Remove a deploy token from the project::
+
+ deploy_token.delete()
+ # or
+ project.deploytokens.delete(deploy_token.id)
+
+
+Group deploy tokens
+===================
+
+Reference
+---------
+
+* v4 API:
+
+ + :class:`gitlab.v4.objects.GroupDeployToken`
+ + :class:`gitlab.v4.objects.GroupDeployTokenManager`
+ + :attr:`gitlab.v4.objects.Group.deploytokens`
+
+* GitLab API: https://docs.gitlab.com/ce/api/deploy_tokens.html#group-deploy-tokens
+
+Examples
+--------
+
+List the deploy tokens for a group::
+
+ deploy_tokens = group.deploytokens.list()
+
+Create a new deploy token to access all repositories of all projects in a group:
+
+In addition to required parameters ``name`` and ``scopes``, this method accepts
+the following parameters:
+
+* ``expires_at`` Expiration date of the deploy token. Does not expire if no value is provided.
+* ``username`` Username for deploy token. Default is ``gitlab+deploy-token-{n}``
+
+::
+
+ deploy_token = group.deploytokens.create({'name': 'token1', 'scopes': ['read_repository'], 'username':'', 'expires_at':''})
+ # show its id
+ print(deploy_token.id)
+
+.. warning::
+
+ With GitLab 12.9, even though ``username`` and ``expires_at`` are not required, they always have to be passed to the API.
+ You can set them to empty strings, see: https://gitlab.com/gitlab-org/gitlab/-/issues/211878.
+ Also, the ``username``'s value is ignored by the API and will be overriden with ``gitlab+deploy-token-{n}``,
+ see: https://gitlab.com/gitlab-org/gitlab/-/issues/211963
+ These issues were fixed in GitLab 12.10.
+
+Remove a deploy token from the group::
+
+ deploy_token.delete()
+ # or
+ group.deploytokens.delete(deploy_token.id)
+
diff --git a/docs/gl_objects/groups.rst b/docs/gl_objects/groups.rst
index 0bc00d9..d3e4d92 100644
--- a/docs/gl_objects/groups.rst
+++ b/docs/gl_objects/groups.rst
@@ -67,6 +67,62 @@ Remove a group::
# or
group.delete()
+Import / Export
+===============
+
+You can export groups from gitlab, and re-import them to create new groups.
+
+Reference
+---------
+
+* v4 API:
+
+ + :class:`gitlab.v4.objects.GroupExport`
+ + :class:`gitlab.v4.objects.GroupExportManager`
+ + :attr:`gitlab.v4.objects.Group.exports`
+ + :class:`gitlab.v4.objects.GroupImport`
+ + :class:`gitlab.v4.objects.GroupImportManager`
+ + :attr:`gitlab.v4.objects.Group.imports`
+ + :attr:`gitlab.v4.objects.GroupManager.import_group`
+
+* GitLab API: https://docs.gitlab.com/ce/api/group_import_export.html
+
+Examples
+--------
+
+A group export is an asynchronous operation. To retrieve the archive
+generated by GitLab you need to:
+
+#. Create an export using the API
+#. Wait for the export to be done
+#. Download the result
+
+.. warning::
+
+ Unlike the Project Export API, GitLab does not provide an export_status
+ for Group Exports. It is up to the user to ensure the export is finished.
+
+ However, Group Exports only contain metadata, so they are much faster
+ than Project Exports.
+
+::
+
+ # Create the export
+ group = gl.groups.get(my_group)
+ export = group.exports.create()
+
+ # Wait for the export to finish
+ time.sleep(3)
+
+ # Download the result
+ with open('/tmp/export.tgz', 'wb') as f:
+ export.download(streamed=True, action=f.write)
+
+Import the group::
+
+ with open('/tmp/export.tgz', 'rb') as f:
+ gl.groups.import_group(f, path='imported-group', name="Imported Group")
+
Subgroups
=========
diff --git a/docs/gl_objects/remote_mirrors.rst b/docs/gl_objects/remote_mirrors.rst
new file mode 100644
index 0000000..ea4f72c
--- /dev/null
+++ b/docs/gl_objects/remote_mirrors.rst
@@ -0,0 +1,34 @@
+##########
+Project Remote Mirrors
+##########
+
+Remote Mirrors allow you to set up push mirroring for a project.
+
+References
+==========
+
+* v4 API:
+
+ + :class:`gitlab.v4.objects.ProjectRemoteMirror`
+ + :class:`gitlab.v4.objects.ProjectRemoteMirrorManager`
+ + :attr:`gitlab.v4.objects.Project.remote_mirrors`
+
+* GitLab API: https://docs.gitlab.com/ce/api/remote_mirrors.html
+
+Examples
+--------
+
+Get the list of a project's remote mirrors::
+
+ mirrors = project.remote_mirrors.list()
+
+Create (and enable) a remote mirror for a project::
+
+ mirror = project.wikis.create({'url': 'https://gitlab.com/example.git',
+ 'enabled': True})
+
+Update an existing remote mirror's attributes::
+
+ mirror.enabled = False
+ mirror.only_protected_branches = True
+ mirror.save()
diff --git a/gitlab/__init__.py b/gitlab/__init__.py
index 3170b41..b5d53de 100644
--- a/gitlab/__init__.py
+++ b/gitlab/__init__.py
@@ -119,6 +119,7 @@ class Gitlab(object):
self.broadcastmessages = objects.BroadcastMessageManager(self)
self.deploykeys = objects.DeployKeyManager(self)
+ self.deploytokens = objects.DeployTokenManager(self)
self.geonodes = objects.GeoNodeManager(self)
self.gitlabciymls = objects.GitlabciymlManager(self)
self.gitignores = objects.GitignoreManager(self)
diff --git a/gitlab/exceptions.py b/gitlab/exceptions.py
index 9feff6d..f95e686 100644
--- a/gitlab/exceptions.py
+++ b/gitlab/exceptions.py
@@ -209,6 +209,10 @@ class GitlabAttachFileError(GitlabOperationError):
pass
+class GitlabImportError(GitlabOperationError):
+ pass
+
+
class GitlabCherryPickError(GitlabOperationError):
pass
diff --git a/gitlab/mixins.py b/gitlab/mixins.py
index dde11d0..9c00c32 100644
--- a/gitlab/mixins.py
+++ b/gitlab/mixins.py
@@ -443,6 +443,35 @@ class AccessRequestMixin(object):
self._update_attrs(server_data)
+class DownloadMixin(object):
+ @cli.register_custom_action(("GroupExport", "ProjectExport"))
+ @exc.on_http_error(exc.GitlabGetError)
+ def download(self, streamed=False, action=None, chunk_size=1024, **kwargs):
+ """Download the archive of a resource export.
+
+ Args:
+ streamed (bool): If True the data will be processed by chunks of
+ `chunk_size` and each chunk is passed to `action` for
+ reatment
+ action (callable): Callable responsible of dealing with chunk of
+ data
+ chunk_size (int): Size of each chunk
+ **kwargs: Extra options to send to the server (e.g. sudo)
+
+ Raises:
+ GitlabAuthenticationError: If authentication is not correct
+ GitlabGetError: If the server failed to perform the request
+
+ Returns:
+ str: The blob content if streamed is False, None otherwise
+ """
+ path = "%s/download" % (self.manager.path)
+ result = self.manager.gitlab.http_get(
+ path, streamed=streamed, raw=True, **kwargs
+ )
+ return utils.response_content(result, streamed, action, chunk_size)
+
+
class SubscribableMixin(object):
@cli.register_custom_action(
("ProjectIssue", "ProjectMergeRequest", "ProjectLabel", "GroupLabel")
diff --git a/gitlab/tests/objects/mocks.py b/gitlab/tests/objects/mocks.py
new file mode 100644
index 0000000..e051339
--- /dev/null
+++ b/gitlab/tests/objects/mocks.py
@@ -0,0 +1,35 @@
+"""Common mocks for resources in gitlab.v4.objects"""
+
+from httmock import response, urlmatch
+
+
+headers = {"content-type": "application/json"}
+binary_content = b"binary content"
+
+
+@urlmatch(
+ scheme="http",
+ netloc="localhost",
+ path="/api/v4/(groups|projects)/1/export",
+ method="post",
+)
+def resp_create_export(url, request):
+ """Common mock for Group/Project Export POST response."""
+ content = """{
+ "message": "202 Accepted"
+ }"""
+ content = content.encode("utf-8")
+ return response(202, content, headers, None, 25, request)
+
+
+@urlmatch(
+ scheme="http",
+ netloc="localhost",
+ path="/api/v4/(groups|projects)/1/export/download",
+ method="get",
+)
+def resp_download_export(url, request):
+ """Common mock for Group/Project Export Download GET response."""
+ headers = {"content-type": "application/octet-stream"}
+ content = binary_content
+ return response(200, content, headers, None, 25, request)
diff --git a/gitlab/tests/objects/test_commits.py b/gitlab/tests/objects/test_commits.py
index 23a4285..7e7c3b4 100644
--- a/gitlab/tests/objects/test_commits.py
+++ b/gitlab/tests/objects/test_commits.py
@@ -1,6 +1,7 @@
from httmock import urlmatch, response, with_httmock
-from .test_projects import headers, TestProject
+from .mocks import headers
+from .test_projects import TestProject
@urlmatch(
diff --git a/gitlab/tests/objects/test_groups.py b/gitlab/tests/objects/test_groups.py
new file mode 100644
index 0000000..075d915
--- /dev/null
+++ b/gitlab/tests/objects/test_groups.py
@@ -0,0 +1,101 @@
+import unittest
+
+from httmock import response, urlmatch, with_httmock
+
+import gitlab
+from .mocks import * # noqa
+
+
+@urlmatch(scheme="http", netloc="localhost", path="/api/v4/groups/1", method="get")
+def resp_get_group(url, request):
+ content = '{"name": "name", "id": 1, "path": "path"}'
+ content = content.encode("utf-8")
+ return response(200, content, headers, None, 5, request)
+
+
+@urlmatch(scheme="http", netloc="localhost", path="/api/v4/groups", method="post")
+def resp_create_group(url, request):
+ content = '{"name": "name", "id": 1, "path": "path"}'
+ content = content.encode("utf-8")
+ return response(200, content, headers, None, 5, request)
+
+
+@urlmatch(
+ scheme="http", netloc="localhost", path="/api/v4/groups/import", method="post",
+)
+def resp_create_import(url, request):
+ """Mock for Group import tests.
+
+ GitLab does not respond with import status for group imports.
+ """
+
+ content = """{
+ "message": "202 Accepted"
+ }"""
+ content = content.encode("utf-8")
+ return response(202, content, headers, None, 25, request)
+
+
+class TestGroup(unittest.TestCase):
+ def setUp(self):
+ self.gl = gitlab.Gitlab(
+ "http://localhost",
+ private_token="private_token",
+ ssl_verify=True,
+ api_version=4,
+ )
+
+ @with_httmock(resp_get_group)
+ def test_get_group(self):
+ data = self.gl.groups.get(1)
+ self.assertIsInstance(data, gitlab.v4.objects.Group)
+ self.assertEqual(data.name, "name")
+ self.assertEqual(data.path, "path")
+ self.assertEqual(data.id, 1)
+
+ @with_httmock(resp_create_group)
+ def test_create_group(self):
+ name, path = "name", "path"
+ data = self.gl.groups.create({"name": name, "path": path})
+ self.assertIsInstance(data, gitlab.v4.objects.Group)
+ self.assertEqual(data.name, name)
+ self.assertEqual(data.path, path)
+
+
+class TestGroupExport(TestGroup):
+ def setUp(self):
+ super(TestGroupExport, self).setUp()
+ self.group = self.gl.groups.get(1, lazy=True)
+
+ @with_httmock(resp_create_export)
+ def test_create_group_export(self):
+ export = self.group.exports.create()
+ self.assertEqual(export.message, "202 Accepted")
+
+ @unittest.skip("GitLab API endpoint not implemented")
+ @with_httmock(resp_create_export)
+ def test_refresh_group_export_status(self):
+ export = self.group.exports.create()
+ export.refresh()
+ self.assertEqual(export.export_status, "finished")
+
+ @with_httmock(resp_create_export, resp_download_export)
+ def test_download_group_export(self):
+ export = self.group.exports.create()
+ download = export.download()
+ self.assertIsInstance(download, bytes)
+ self.assertEqual(download, binary_content)
+
+
+class TestGroupImport(TestGroup):
+ @with_httmock(resp_create_import)
+ def test_import_group(self):
+ group_import = self.gl.groups.import_group("file", "api-group", "API Group")
+ self.assertEqual(group_import["message"], "202 Accepted")
+
+ @unittest.skip("GitLab API endpoint not implemented")
+ @with_httmock(resp_create_import)
+ def test_refresh_group_import_status(self):
+ group_import = self.group.imports.get()
+ group_import.refresh()
+ self.assertEqual(group_import.import_status, "finished")
diff --git a/gitlab/tests/objects/test_projects.py b/gitlab/tests/objects/test_projects.py
index d87f759..48347f9 100644
--- a/gitlab/tests/objects/test_projects.py
+++ b/gitlab/tests/objects/test_projects.py
@@ -10,21 +10,7 @@ from gitlab import * # noqa
from gitlab.v4.objects import * # noqa
from httmock import HTTMock, urlmatch, response, with_httmock # noqa
-
-headers = {"content-type": "application/json"}
-binary_content = b"binary content"
-
-
-@urlmatch(
- scheme="http", netloc="localhost", path="/api/v4/projects/1/export", method="post",
-)
-def resp_create_export(url, request):
- """Common mock for Project Export tests."""
- content = """{
- "message": "202 Accepted"
- }"""
- content = content.encode("utf-8")
- return response(202, content, headers, None, 25, request)
+from .mocks import * # noqa
@urlmatch(
@@ -52,19 +38,6 @@ def resp_export_status(url, request):
@urlmatch(
- scheme="http",
- netloc="localhost",
- path="/api/v4/projects/1/export/download",
- method="get",
-)
-def resp_download_export(url, request):
- """Mock for Project Export Download GET response."""
- headers = {"content-type": "application/octet-stream"}
- content = binary_content
- return response(200, content, headers, None, 25, request)
-
-
-@urlmatch(
scheme="http", netloc="localhost", path="/api/v4/projects/import", method="post",
)
def resp_import_project(url, request):
@@ -117,6 +90,77 @@ def resp_import_github(url, request):
return response(200, content, headers, None, 25, request)
+@urlmatch(
+ scheme="http",
+ netloc="localhost",
+ path="/api/v4/projects/1/remote_mirrors",
+ method="get",
+)
+def resp_get_remote_mirrors(url, request):
+ """Mock for Project Remote Mirrors GET response."""
+ content = """[
+ {
+ "enabled": true,
+ "id": 101486,
+ "last_error": null,
+ "last_successful_update_at": "2020-01-06T17:32:02.823Z",
+ "last_update_at": "2020-01-06T17:32:02.823Z",
+ "last_update_started_at": "2020-01-06T17:31:55.864Z",
+ "only_protected_branches": true,
+ "update_status": "finished",
+ "url": "https://*****:*****@gitlab.com/gitlab-org/security/gitlab.git"
+ }
+ ]"""
+ content = content.encode("utf-8")
+ return response(200, content, headers, None, 5, request)
+
+
+@urlmatch(
+ scheme="http",
+ netloc="localhost",
+ path="/api/v4/projects/1/remote_mirrors",
+ method="post",
+)
+def resp_create_remote_mirror(url, request):
+ """Mock for Project Remote Mirrors POST response."""
+ content = """{
+ "enabled": false,
+ "id": 101486,
+ "last_error": null,
+ "last_successful_update_at": null,
+ "last_update_at": null,
+ "last_update_started_at": null,
+ "only_protected_branches": false,
+ "update_status": "none",
+ "url": "https://*****:*****@example.com/gitlab/example.git"
+ }"""
+ content = content.encode("utf-8")
+ return response(200, content, headers, None, 5, request)
+
+
+@urlmatch(
+ scheme="http",
+ netloc="localhost",
+ path="/api/v4/projects/1/remote_mirrors/1",
+ method="put",
+)
+def resp_update_remote_mirror(url, request):
+ """Mock for Project Remote Mirrors PUT response."""
+ content = """{
+ "enabled": false,
+ "id": 101486,
+ "last_error": null,
+ "last_successful_update_at": "2020-01-06T17:32:02.823Z",
+ "last_update_at": "2020-01-06T17:32:02.823Z",
+ "last_update_started_at": "2020-01-06T17:31:55.864Z",
+ "only_protected_branches": true,
+ "update_status": "finished",
+ "url": "https://*****:*****@gitlab.com/gitlab-org/security/gitlab.git"
+ }"""
+ content = content.encode("utf-8")
+ return response(200, content, headers, None, 5, request)
+
+
class TestProject(unittest.TestCase):
"""Base class for GitLab Project tests."""
@@ -289,3 +333,26 @@ class TestProjectImport(TestProject):
self.assertEqual(ret["name"], name)
self.assertEqual(ret["full_path"], "/".join((base_path, name)))
self.assertTrue(ret["full_name"].endswith(name))
+
+
+class TestProjectRemoteMirrors(TestProject):
+ @with_httmock(resp_get_remote_mirrors)
+ def test_list_project_remote_mirrors(self):
+ mirrors = self.project.remote_mirrors.list()
+ self.assertIsInstance(mirrors, list)
+ self.assertIsInstance(mirrors[0], ProjectRemoteMirror)
+ self.assertTrue(mirrors[0].enabled)
+
+ @with_httmock(resp_create_remote_mirror)
+ def test_create_project_remote_mirror(self):
+ mirror = self.project.remote_mirrors.create({"url": "https://example.com"})
+ self.assertIsInstance(mirror, ProjectRemoteMirror)
+ self.assertEqual(mirror.update_status, "none")
+
+ @with_httmock(resp_create_remote_mirror, resp_update_remote_mirror)
+ def test_update_project_remote_mirror(self):
+ mirror = self.project.remote_mirrors.create({"url": "https://example.com"})
+ mirror.only_protected_branches = True
+ mirror.save()
+ self.assertEqual(mirror.update_status, "finished")
+ self.assertTrue(mirror.only_protected_branches)
diff --git a/gitlab/tests/test_gitlab.py b/gitlab/tests/test_gitlab.py
index d104c7d..6fc551c 100644
--- a/gitlab/tests/test_gitlab.py
+++ b/gitlab/tests/test_gitlab.py
@@ -626,23 +626,6 @@ class TestGitlab(unittest.TestCase):
self.assertIsInstance(statistics, ProjectIssuesStatistics)
self.assertEqual(statistics.statistics["counts"]["all"], 20)
- def test_groups(self):
- @urlmatch(
- scheme="http", netloc="localhost", path="/api/v4/groups/1", method="get"
- )
- def resp_get_group(url, request):
- headers = {"content-type": "application/json"}
- content = '{"name": "name", "id": 1, "path": "path"}'
- content = content.encode("utf-8")
- return response(200, content, headers, None, 5, request)
-
- with HTTMock(resp_get_group):
- data = self.gl.groups.get(1)
- self.assertIsInstance(data, Group)
- self.assertEqual(data.name, "name")
- self.assertEqual(data.path, "path")
- self.assertEqual(data.id, 1)
-
def test_issues(self):
@urlmatch(
scheme="http", netloc="localhost", path="/api/v4/issues", method="get"
@@ -920,6 +903,40 @@ class TestGitlab(unittest.TestCase):
self.assertEqual(application.redirect_uri, "http://localhost:8080")
self.assertEqual(application.scopes, ["api", "email"])
+ def test_deploy_tokens(self):
+ @urlmatch(
+ scheme="http",
+ netloc="localhost",
+ path="/api/v4/projects/1/deploy_tokens",
+ method="post",
+ )
+ def resp_deploy_token_create(url, request):
+ headers = {"content-type": "application/json"}
+ content = """{
+ "id": 1,
+ "name": "test_deploy_token",
+ "username": "custom-user",
+ "expires_at": "2022-01-01T00:00:00.000Z",
+ "token": "jMRvtPNxrn3crTAGukpZ",
+ "scopes": [ "read_repository" ]}"""
+ content = content.encode("utf-8")
+ return response(200, content, headers, None, 5, request)
+
+ with HTTMock(resp_deploy_token_create):
+ deploy_token = self.gl.projects.get(1, lazy=True).deploytokens.create(
+ {
+ "name": "test_deploy_token",
+ "expires_at": "2022-01-01T00:00:00.000Z",
+ "username": "custom-user",
+ "scopes": ["read_repository"],
+ }
+ )
+ self.assertIsInstance(deploy_token, ProjectDeployToken)
+ self.assertEqual(deploy_token.id, 1),
+ self.assertEqual(deploy_token.expires_at, "2022-01-01T00:00:00.000Z"),
+ self.assertEqual(deploy_token.username, "custom-user")
+ self.assertEqual(deploy_token.scopes, ["read_repository"])
+
def _default_config(self):
fd, temp_path = tempfile.mkstemp()
os.write(fd, valid_config)
diff --git a/gitlab/tests/test_types.py b/gitlab/tests/test_types.py
index 5b9f2ca..3613383 100644
--- a/gitlab/tests/test_types.py
+++ b/gitlab/tests/test_types.py
@@ -51,11 +51,19 @@ class TestListAttribute(unittest.TestCase):
o.set_from_cli(" ")
self.assertEqual([], o.get())
- def test_get_for_api(self):
+ def test_get_for_api_from_cli(self):
o = types.ListAttribute()
o.set_from_cli("foo,bar,baz")
self.assertEqual("foo,bar,baz", o.get_for_api())
+ def test_get_for_api_from_list(self):
+ o = types.ListAttribute(["foo", "bar", "baz"])
+ self.assertEqual("foo,bar,baz", o.get_for_api())
+
+ def test_get_for_api_does_not_split_string(self):
+ o = types.ListAttribute("foo")
+ self.assertEqual("foo", o.get_for_api())
+
class TestLowercaseStringAttribute(unittest.TestCase):
def test_get_for_api(self):
diff --git a/gitlab/types.py b/gitlab/types.py
index 525dc30..e07d078 100644
--- a/gitlab/types.py
+++ b/gitlab/types.py
@@ -38,6 +38,10 @@ class ListAttribute(GitlabAttribute):
self._value = [item.strip() for item in cli_value.split(",")]
def get_for_api(self):
+ # Do not comma-split single value passed as string
+ if isinstance(self._value, str):
+ return self._value
+
return ",".join(self._value)
diff --git a/gitlab/v4/objects.py b/gitlab/v4/objects.py
index a2f6153..994df38 100644
--- a/gitlab/v4/objects.py
+++ b/gitlab/v4/objects.py
@@ -695,6 +695,43 @@ class DeployKeyManager(ListMixin, RESTManager):
_obj_cls = DeployKey
+class DeployToken(ObjectDeleteMixin, RESTObject):
+ pass
+
+
+class DeployTokenManager(ListMixin, RESTManager):
+ _path = "/deploy_tokens"
+ _obj_cls = DeployToken
+
+
+class ProjectDeployToken(ObjectDeleteMixin, RESTObject):
+ pass
+
+
+class ProjectDeployTokenManager(ListMixin, CreateMixin, DeleteMixin, RESTManager):
+ _path = "/projects/%(project_id)s/deploy_tokens"
+ _from_parent_attrs = {"project_id": "id"}
+ _obj_cls = ProjectDeployToken
+ _create_attrs = (
+ ("name", "scopes",),
+ ("expires_at", "username",),
+ )
+
+
+class GroupDeployToken(ObjectDeleteMixin, RESTObject):
+ pass
+
+
+class GroupDeployTokenManager(ListMixin, CreateMixin, DeleteMixin, RESTManager):
+ _path = "/groups/%(group_id)s/deploy_tokens"
+ _from_parent_attrs = {"group_id": "id"}
+ _obj_cls = GroupDeployToken
+ _create_attrs = (
+ ("name", "scopes",),
+ ("expires_at", "username",),
+ )
+
+
class NotificationSettings(SaveMixin, RESTObject):
_id_attr = None
@@ -991,6 +1028,26 @@ class GroupEpicManager(CRUDMixin, RESTManager):
_types = {"labels": types.ListAttribute}
+class GroupExport(DownloadMixin, RESTObject):
+ _id_attr = None
+
+
+class GroupExportManager(GetWithoutIdMixin, CreateMixin, RESTManager):
+ _path = "/groups/%(group_id)s/export"
+ _obj_cls = GroupExport
+ _from_parent_attrs = {"group_id": "id"}
+
+
+class GroupImport(RESTObject):
+ _id_attr = None
+
+
+class GroupImportManager(GetWithoutIdMixin, RESTManager):
+ _path = "/groups/%(group_id)s/import"
+ _obj_cls = GroupImport
+ _from_parent_attrs = {"group_id": "id"}
+
+
class GroupIssue(RESTObject):
pass
@@ -1290,7 +1347,9 @@ class Group(SaveMixin, ObjectDeleteMixin, RESTObject):
("badges", "GroupBadgeManager"),
("boards", "GroupBoardManager"),
("customattributes", "GroupCustomAttributeManager"),
+ ("exports", "GroupExportManager"),
("epics", "GroupEpicManager"),
+ ("imports", "GroupImportManager"),
("issues", "GroupIssueManager"),
("labels", "GroupLabelManager"),
("members", "GroupMemberManager"),
@@ -1301,6 +1360,7 @@ class Group(SaveMixin, ObjectDeleteMixin, RESTObject):
("subgroups", "GroupSubgroupManager"),
("variables", "GroupVariableManager"),
("clusters", "GroupClusterManager"),
+ ("deploytokens", "GroupDeployTokenManager"),
)
@cli.register_custom_action("Group", ("to_project_id",))
@@ -1408,15 +1468,27 @@ class GroupManager(CRUDMixin, RESTManager):
"statistics",
"owned",
"with_custom_attributes",
+ "min_access_level",
)
_create_attrs = (
("name", "path"),
(
"description",
+ "membership_lock",
"visibility",
- "parent_id",
+ "share_with_group_lock",
+ "require_two_factor_authentication",
+ "two_factor_grace_period",
+ "project_creation_level",
+ "auto_devops_enabled",
+ "subgroup_creation_level",
+ "emails_disabled",
+ "avatar",
+ "mentions_disabled",
"lfs_enabled",
"request_access_enabled",
+ "parent_id",
+ "default_branch_protection",
),
)
_update_attrs = (
@@ -1425,12 +1497,51 @@ class GroupManager(CRUDMixin, RESTManager):
"name",
"path",
"description",
+ "membership_lock",
+ "share_with_group_lock",
"visibility",
+ "require_two_factor_authentication",
+ "two_factor_grace_period",
+ "project_creation_level",
+ "auto_devops_enabled",
+ "subgroup_creation_level",
+ "emails_disabled",
+ "avatar",
+ "mentions_disabled",
"lfs_enabled",
"request_access_enabled",
+ "default_branch_protection",
),
)
+ @exc.on_http_error(exc.GitlabImportError)
+ def import_group(self, file, path, name, parent_id=None, **kwargs):
+ """Import a group from an archive file.
+
+ Args:
+ file: Data or file object containing the group
+ path (str): The path for the new group to be imported.
+ name (str): The name for the new group.
+ parent_id (str): ID of a parent group that the group will
+ be imported into.
+ **kwargs: Extra options to send to the server (e.g. sudo)
+
+ Raises:
+ GitlabAuthenticationError: If authentication is not correct
+ GitlabImportError: If the server failed to perform the request
+
+ Returns:
+ dict: A representation of the import status.
+ """
+ files = {"file": ("file.tar.gz", file)}
+ data = {"path": path, "name": name}
+ if parent_id is not None:
+ data["parent_id"] = parent_id
+
+ return self.gitlab.http_post(
+ "/groups/import", post_data=data, files=files, **kwargs
+ )
+
class Hook(ObjectDeleteMixin, RESTObject):
_url = "/hooks"
@@ -1672,6 +1783,18 @@ class ProjectRegistryTagManager(DeleteMixin, RetrieveMixin, RESTManager):
self.gitlab.http_delete(self.path, query_data=data, **kwargs)
+class ProjectRemoteMirror(SaveMixin, RESTObject):
+ pass
+
+
+class ProjectRemoteMirrorManager(ListMixin, CreateMixin, UpdateMixin, RESTManager):
+ _path = "/projects/%(project_id)s/remote_mirrors"
+ _obj_cls = ProjectRemoteMirror
+ _from_parent_attrs = {"project_id": "id"}
+ _create_attrs = (("url",), ("enabled", "only_protected_branches"))
+ _update_attrs = (tuple(), ("enabled", "only_protected_branches"))
+
+
class ProjectBoardList(SaveMixin, ObjectDeleteMixin, RESTObject):
pass
@@ -4097,36 +4220,9 @@ class ProjectWikiManager(CRUDMixin, RESTManager):
_list_filters = ("with_content",)
-class ProjectExport(RefreshMixin, RESTObject):
+class ProjectExport(DownloadMixin, RefreshMixin, RESTObject):
_id_attr = None
- @cli.register_custom_action("ProjectExport")
- @exc.on_http_error(exc.GitlabGetError)
- def download(self, streamed=False, action=None, chunk_size=1024, **kwargs):
- """Download the archive of a project export.
-
- Args:
- streamed (bool): If True the data will be processed by chunks of
- `chunk_size` and each chunk is passed to `action` for
- reatment
- action (callable): Callable responsible of dealing with chunk of
- data
- chunk_size (int): Size of each chunk
- **kwargs: Extra options to send to the server (e.g. sudo)
-
- Raises:
- GitlabAuthenticationError: If authentication is not correct
- GitlabGetError: If the server failed to perform the request
-
- Returns:
- str: The blob content if streamed is False, None otherwise
- """
- path = "/projects/%s/export/download" % self.project_id
- result = self.manager.gitlab.http_get(
- path, streamed=streamed, raw=True, **kwargs
- )
- return utils.response_content(result, streamed, action, chunk_size)
-
class ProjectExportManager(GetWithoutIdMixin, CreateMixin, RESTManager):
_path = "/projects/%(project_id)s/export"
@@ -4200,6 +4296,7 @@ class Project(SaveMixin, ObjectDeleteMixin, RESTObject):
("pipelineschedules", "ProjectPipelineScheduleManager"),
("pushrules", "ProjectPushRulesManager"),
("releases", "ProjectReleaseManager"),
+ ("remote_mirrors", "ProjectRemoteMirrorManager"),
("repositories", "ProjectRegistryRepositoryManager"),
("runners", "ProjectRunnerManager"),
("services", "ProjectServiceManager"),
@@ -4212,6 +4309,7 @@ class Project(SaveMixin, ObjectDeleteMixin, RESTObject):
("clusters", "ProjectClusterManager"),
("additionalstatistics", "ProjectAdditionalStatisticsManager"),
("issuesstatistics", "ProjectIssuesStatisticsManager"),
+ ("deploytokens", "ProjectDeployTokenManager"),
)
@cli.register_custom_action("Project", ("submodule", "branch", "commit_sha"))
diff --git a/tools/cli_test_v4.sh b/tools/cli_test_v4.sh
index b916705..395289a 100755
--- a/tools/cli_test_v4.sh
+++ b/tools/cli_test_v4.sh
@@ -195,14 +195,6 @@ testcase "project upload" '
--filename '$(basename $0)' --filepath '$0' >/dev/null 2>&1
'
-testcase "project deletion" '
- GITLAB project delete --id "$PROJECT_ID"
-'
-
-testcase "group deletion" '
- OUTPUT=$(try GITLAB group delete --id $GROUP_ID)
-'
-
testcase "application settings get" '
GITLAB application-settings get >/dev/null 2>&1
'
@@ -222,3 +214,83 @@ testcase "values from files" '
echo $OUTPUT | grep -q "Multi line"
'
+# Test deploy tokens
+CREATE_PROJECT_DEPLOY_TOKEN_OUTPUT=$(GITLAB -v project-deploy-token create --project-id $PROJECT_ID \
+ --name foo --username root --expires-at "2021-09-09" --scopes "read_registry")
+CREATED_DEPLOY_TOKEN_ID=$(echo "$CREATE_PROJECT_DEPLOY_TOKEN_OUTPUT" | grep ^id: | cut -d" " -f2)
+testcase "create project deploy token" '
+ echo $CREATE_PROJECT_DEPLOY_TOKEN_OUTPUT | grep -q "name: foo"
+'
+testcase "create project deploy token" '
+ echo $CREATE_PROJECT_DEPLOY_TOKEN_OUTPUT | grep -q "expires-at: 2021-09-09T00:00:00.000Z"
+'
+testcase "create project deploy token" '
+ echo $CREATE_PROJECT_DEPLOY_TOKEN_OUTPUT | grep "scopes: " | grep -q "read_registry"
+'
+# Uncomment once https://gitlab.com/gitlab-org/gitlab/-/issues/211963 is fixed
+#testcase "create project deploy token" '
+# echo $CREATE_PROJECT_DEPLOY_TOKEN_OUTPUT | grep -q "username: root"
+#'
+
+# Remove once https://gitlab.com/gitlab-org/gitlab/-/issues/211963 is fixed
+testcase "create project deploy token" '
+ echo $CREATE_PROJECT_DEPLOY_TOKEN_OUTPUT | grep -q "gitlab+deploy-token"
+'
+
+LIST_DEPLOY_TOKEN_OUTPUT=$(GITLAB -v deploy-token list)
+testcase "list all deploy tokens" '
+ echo $LIST_DEPLOY_TOKEN_OUTPUT | grep -q "name: foo"
+'
+testcase "list all deploy tokens" '
+ echo $LIST_DEPLOY_TOKEN_OUTPUT | grep -q "id: $CREATED_DEPLOY_TOKEN_ID"
+'
+testcase "list all deploy tokens" '
+ echo $LIST_DEPLOY_TOKEN_OUTPUT | grep -q "expires-at: 2021-09-09T00:00:00.000Z"
+'
+testcase "list all deploy tokens" '
+ echo $LIST_DEPLOY_TOKEN_OUTPUT | grep "scopes: " | grep -q "read_registry"
+'
+
+testcase "list project deploy tokens" '
+ OUTPUT=$(GITLAB -v project-deploy-token list --project-id $PROJECT_ID)
+ echo $OUTPUT | grep -q "id: $CREATED_DEPLOY_TOKEN_ID"
+'
+testcase "delete project deploy token" '
+ GITLAB -v project-deploy-token delete --project-id $PROJECT_ID --id $CREATED_DEPLOY_TOKEN_ID
+ LIST_PROJECT_DEPLOY_TOKEN_OUTPUT=$(GITLAB -v project-deploy-token list --project-id $PROJECT_ID)
+ echo $LIST_PROJECT_DEPLOY_TOKEN_OUTPUT | grep -qv "id: $CREATED_DEPLOY_TOKEN_ID"
+'
+# Uncomment once https://gitlab.com/gitlab-org/gitlab/-/issues/212523 is fixed
+#testcase "delete project deploy token" '
+# LIST_DEPLOY_TOKEN_OUTPUT=$(GITLAB -v deploy-token list)
+# echo $LIST_DEPLOY_TOKEN_OUTPUT | grep -qv "id: $CREATED_DEPLOY_TOKEN_ID"
+#'
+
+CREATE_GROUP_DEPLOY_TOKEN_OUTPUT=$(GITLAB -v group-deploy-token create --group-id $GROUP_ID \
+ --name bar --username root --expires-at "2021-09-09" --scopes "read_repository")
+CREATED_DEPLOY_TOKEN_ID=$(echo "$CREATE_GROUP_DEPLOY_TOKEN_OUTPUT" | grep ^id: | cut -d" " -f2)
+testcase "create group deploy token" '
+ echo $CREATE_GROUP_DEPLOY_TOKEN_OUTPUT | grep -q "name: bar"
+'
+testcase "list group deploy tokens" '
+ OUTPUT=$(GITLAB -v group-deploy-token list --group-id $GROUP_ID)
+ echo $OUTPUT | grep -q "id: $CREATED_DEPLOY_TOKEN_ID"
+'
+testcase "delete group deploy token" '
+ GITLAB -v group-deploy-token delete --group-id $GROUP_ID --id $CREATED_DEPLOY_TOKEN_ID
+ LIST_GROUP_DEPLOY_TOKEN_OUTPUT=$(GITLAB -v group-deploy-token list --group-id $GROUP_ID)
+ echo $LIST_GROUP_DEPLOY_TOKEN_OUTPUT | grep -qv "id: $CREATED_DEPLOY_TOKEN_ID"
+'
+# Uncomment once https://gitlab.com/gitlab-org/gitlab/-/issues/212523 is fixed
+#testcase "delete group deploy token" '
+# LIST_DEPLOY_TOKEN_OUTPUT=$(GITLAB -v deploy-token list)
+# echo $LIST_DEPLOY_TOKEN_OUTPUT | grep -qv "id: $CREATED_DEPLOY_TOKEN_ID"
+#'
+
+testcase "project deletion" '
+ GITLAB project delete --id "$PROJECT_ID"
+'
+
+testcase "group deletion" '
+ OUTPUT=$(try GITLAB group delete --id $GROUP_ID)
+'
diff --git a/tools/python_test_v4.py b/tools/python_test_v4.py
index 69b0d31..7145bc1 100644
--- a/tools/python_test_v4.py
+++ b/tools/python_test_v4.py
@@ -380,6 +380,33 @@ assert len(group1.variables.list()) == 0
# g_l.delete()
# assert len(group1.labels.list()) == 0
+
+# group import/export
+export = group1.exports.create()
+assert export.message == "202 Accepted"
+
+# We cannot check for export_status with group export API
+time.sleep(10)
+
+import_archive = "/tmp/gitlab-group-export.tgz"
+import_path = "imported_group"
+import_name = "Imported Group"
+
+with open(import_archive, "wb") as f:
+ export.download(streamed=True, action=f.write)
+
+with open(import_archive, "rb") as f:
+ output = gl.groups.import_group(f, import_path, import_name)
+assert output["message"] == "202 Accepted"
+
+# We cannot check for returned ID with group import API
+time.sleep(10)
+group_import = gl.groups.get(import_path)
+
+assert group_import.path == import_path
+assert group_import.name == import_name
+
+
# hooks
hook = gl.hooks.create({"url": "http://whatever.com"})
assert len(gl.hooks.list()) == 1
@@ -625,6 +652,56 @@ assert len(sudo_project.keys.list()) == 1
sudo_project.keys.delete(deploy_key.id)
assert len(sudo_project.keys.list()) == 0
+# deploy tokens
+deploy_token = admin_project.deploytokens.create(
+ {
+ "name": "foo",
+ "username": "bar",
+ "expires_at": "2022-01-01",
+ "scopes": ["read_registry"],
+ }
+)
+assert len(admin_project.deploytokens.list()) == 1
+assert gl.deploytokens.list() == admin_project.deploytokens.list()
+
+assert admin_project.deploytokens.list()[0].name == "foo"
+assert admin_project.deploytokens.list()[0].expires_at == "2022-01-01T00:00:00.000Z"
+assert admin_project.deploytokens.list()[0].scopes == ["read_registry"]
+# Uncomment once https://gitlab.com/gitlab-org/gitlab/-/issues/211963 is fixed
+# assert admin_project.deploytokens.list()[0].username == "bar"
+deploy_token.delete()
+assert len(admin_project.deploytokens.list()) == 0
+# Uncomment once https://gitlab.com/gitlab-org/gitlab/-/issues/212523 is fixed
+# assert len(gl.deploytokens.list()) == 0
+
+
+deploy_token_group = gl.groups.create(
+ {"name": "deploy_token_group", "path": "deploy_token_group"}
+)
+
+# Uncomment once https://gitlab.com/gitlab-org/gitlab/-/issues/211878 is fixed
+# deploy_token = group_deploy_token.deploytokens.create(
+# {
+# "name": "foo",
+# "scopes": ["read_registry"],
+# }
+# )
+
+# Remove once https://gitlab.com/gitlab-org/gitlab/-/issues/211878 is fixed
+deploy_token = deploy_token_group.deploytokens.create(
+ {"name": "foo", "username": "", "expires_at": "", "scopes": ["read_repository"],}
+)
+
+assert len(deploy_token_group.deploytokens.list()) == 1
+# Uncomment once https://gitlab.com/gitlab-org/gitlab/-/issues/212523 is fixed
+# assert gl.deploytokens.list() == deploy_token_group.deploytokens.list()
+deploy_token.delete()
+assert len(deploy_token_group.deploytokens.list()) == 0
+# Uncomment once https://gitlab.com/gitlab-org/gitlab/-/issues/212523 is fixed
+# assert len(gl.deploytokens.list()) == 0
+
+deploy_token_group.delete()
+
# labels
# label1 = admin_project.labels.create({"name": "label1", "color": "#778899"})
# label1 = admin_project.labels.list()[0]
@@ -677,10 +754,17 @@ assert issue1.participants()
assert type(issue1.closed_by()) == list
assert type(issue1.related_merge_requests()) == list
-# issues labels and events
+# issue labels
label2 = admin_project.labels.create({"name": "label2", "color": "#aabbcc"})
issue1.labels = ["label2"]
issue1.save()
+
+assert issue1 in admin_project.issues.list(labels=["label2"])
+assert issue1 in admin_project.issues.list(labels="label2")
+assert issue1 in admin_project.issues.list(labels="Any")
+assert issue1 not in admin_project.issues.list(labels="None")
+
+# issue events
events = issue1.resourcelabelevents.list()
assert events
event = issue1.resourcelabelevents.get(events[0].id)
@@ -1013,6 +1097,23 @@ release_test_project.releases.delete(release_tag_name)
assert len(release_test_project.releases.list()) == 0
release_test_project.delete()
+# project remote mirrors
+mirror_url = "http://gitlab.test/root/mirror.git"
+
+# create remote mirror
+mirror = admin_project.remote_mirrors.create({"url": mirror_url})
+assert mirror.url == mirror_url
+
+# update remote mirror
+mirror.enabled = True
+mirror.save()
+
+# list remote mirrors
+mirror = admin_project.remote_mirrors.list()[0]
+assert isinstance(mirror, gitlab.v4.objects.ProjectRemoteMirror)
+assert mirror.url == mirror_url
+assert mirror.enabled is True
+
# status
message = "Test"
emoji = "thumbsup"