summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/switching-to-v4.rst10
-rw-r--r--gitlab/__init__.py24
-rw-r--r--gitlab/mixins.py2
-rw-r--r--gitlab/v4/objects.py153
-rwxr-xr-xtools/build_test_env.sh2
-rwxr-xr-xtools/py_functional_tests.sh2
-rw-r--r--tools/python_test_v3.py (renamed from tools/python_test.py)0
-rw-r--r--tools/python_test_v4.py341
8 files changed, 489 insertions, 45 deletions
diff --git a/docs/switching-to-v4.rst b/docs/switching-to-v4.rst
index fcec8a8..fb2b978 100644
--- a/docs/switching-to-v4.rst
+++ b/docs/switching-to-v4.rst
@@ -115,11 +115,17 @@ following important changes in the python API:
+ :attr:`~gitlab.Gitlab.http_put`
+ :attr:`~gitlab.Gitlab.http_delete`
+* The users ``get_by_username`` method has been removed. It doesn't exist in
+ the GitLab API. You can use the ``username`` filter attribute when listing to
+ get a similar behavior:
+
+ .. code-block:: python
+
+ user = list(gl.users.list(username='jdoe'))[0]
+
Undergoing work
===============
-* The ``delete()`` method for objects is not yet available. For now you need to
- use ``manager.delete(obj.id)``.
* The ``page`` and ``per_page`` arguments for listing don't behave as they used
to. Their behavior will be restored.
diff --git a/gitlab/__init__.py b/gitlab/__init__.py
index 6a55fee..617f50c 100644
--- a/gitlab/__init__.py
+++ b/gitlab/__init__.py
@@ -645,12 +645,32 @@ class Gitlab(object):
Raises:
GitlabHttpError: When the return code is not 2xx
"""
+
+ def sanitized_url(url):
+ parsed = six.moves.urllib.parse.urlparse(url)
+ new_path = parsed.path.replace('.', '%2E')
+ return parsed._replace(path=new_path).geturl()
+
url = self._build_url(path)
params = query_data.copy()
params.update(kwargs)
opts = self._get_session_opts(content_type='application/json')
- result = self.session.request(verb, url, json=post_data,
- params=params, stream=streamed, **opts)
+ verify = opts.pop('verify')
+ timeout = opts.pop('timeout')
+
+ # Requests assumes that `.` should not be encoded as %2E and will make
+ # changes to urls using this encoding. Using a prepped request we can
+ # get the desired behavior.
+ # The Requests behavior is right but it seems that web servers don't
+ # always agree with this decision (this is the case with a default
+ # gitlab installation)
+ req = requests.Request(verb, url, json=post_data, params=params,
+ **opts)
+ prepped = self.session.prepare_request(req)
+ prepped.url = sanitized_url(prepped.url)
+ result = self.session.send(prepped, stream=streamed, verify=verify,
+ timeout=timeout)
+
if 200 <= result.status_code < 300:
return result
diff --git a/gitlab/mixins.py b/gitlab/mixins.py
index cc9eb51..5876d58 100644
--- a/gitlab/mixins.py
+++ b/gitlab/mixins.py
@@ -152,7 +152,7 @@ class CreateMixin(object):
**kwargs: Extra options to send to the Gitlab server (e.g. sudo)
Returns:
- RESTObject: a new instance of the managed object class build with
+ RESTObject: a new instance of the managed object class built with
the data sent by the server
Raises:
diff --git a/gitlab/v4/objects.py b/gitlab/v4/objects.py
index 9de18ee..b94d84a 100644
--- a/gitlab/v4/objects.py
+++ b/gitlab/v4/objects.py
@@ -126,7 +126,7 @@ class UserKey(ObjectDeleteMixin, RESTObject):
class UserKeyManager(GetFromListMixin, CreateMixin, DeleteMixin, RESTManager):
- _path = '/users/%(user_id)s/emails'
+ _path = '/users/%(user_id)s/keys'
_obj_cls = UserKey
_from_parent_attrs = {'user_id': 'id'}
_create_attrs = (('title', 'key'), tuple())
@@ -842,8 +842,8 @@ class ProjectKeyManager(NoUpdateMixin, RESTManager):
GitlabAuthenticationError: If authentication is not correct
GitlabProjectDeployKeyError: If the key could not be enabled
"""
- path = '%s/%s/enable' % (self.manager.path, key_id)
- self.manager.gitlab.http_post(path, **kwargs)
+ path = '%s/%s/enable' % (self.path, key_id)
+ self.gitlab.http_post(path, **kwargs)
class ProjectEvent(RESTObject):
@@ -999,17 +999,19 @@ class ProjectTag(ObjectDeleteMixin, RESTObject):
data = {'description': description}
if self.release is None:
try:
- result = self.manager.gitlab.http_post(path, post_data=data,
- **kwargs)
+ server_data = self.manager.gitlab.http_post(path,
+ post_data=data,
+ **kwargs)
except exc.GitlabHttpError as e:
raise exc.GitlabCreateError(e.response_code, e.error_message)
else:
try:
- result = self.manager.gitlab.http_put(path, post_data=data,
- **kwargs)
+ server_data = self.manager.gitlab.http_put(path,
+ post_data=data,
+ **kwargs)
except exc.GitlabHttpError as e:
raise exc.GitlabUpdateError(e.response_code, e.error_message)
- self.release = result.json()
+ self.release = server_data
class ProjectTagManager(GetFromListMixin, CreateMixin, DeleteMixin,
@@ -1223,8 +1225,7 @@ class ProjectMilestone(SaveMixin, ObjectDeleteMixin, RESTObject):
return RESTObjectList(manager, ProjectMergeRequest, data_list)
-class ProjectMilestoneManager(RetrieveMixin, CreateMixin, DeleteMixin,
- RESTManager):
+class ProjectMilestoneManager(CRUDMixin, RESTManager):
_path = '/projects/%(project_id)s/milestones'
_obj_cls = ProjectMilestone
_from_parent_attrs = {'project_id': 'id'}
@@ -1239,6 +1240,26 @@ class ProjectLabel(SubscribableMixin, SaveMixin, ObjectDeleteMixin,
RESTObject):
_id_attr = 'name'
+ # Update without ID, but we need an ID to get from list.
+ @exc.on_http_error(exc.GitlabUpdateError)
+ def save(self, **kwargs):
+ """Saves the changes made to the object to the server.
+
+ The object is updated to match what the server returns.
+
+ Args:
+ **kwargs: Extra options to send to the server (e.g. sudo)
+
+ Raises:
+ GitlabAuthenticationError: If authentication is not correct.
+ GitlabUpdateError: If the server cannot perform the request.
+ """
+ updated_data = self._get_updated_data()
+
+ # call the manager
+ server_data = self.manager.update(None, updated_data, **kwargs)
+ self._update_attrs(server_data)
+
class ProjectLabelManager(GetFromListMixin, CreateMixin, UpdateMixin,
DeleteMixin, RESTManager):
@@ -1262,27 +1283,7 @@ class ProjectLabelManager(GetFromListMixin, CreateMixin, UpdateMixin,
GitlabAuthenticationError: If authentication is not correct.
GitlabDeleteError: If the server cannot perform the request.
"""
- self.gitlab.http_delete(path, query_data={'name': self.name}, **kwargs)
-
- # Update without ID, but we need an ID to get from list.
- @exc.on_http_error(exc.GitlabUpdateError)
- def save(self, **kwargs):
- """Saves the changes made to the object to the server.
-
- The object is updated to match what the server returns.
-
- Args:
- **kwargs: Extra options to send to the server (e.g. sudo)
-
- Raises:
- GitlabAuthenticationError: If authentication is not correct.
- GitlabUpdateError: If the server cannot perform the request.
- """
- updated_data = self._get_updated_data()
-
- # call the manager
- server_data = self.manager.update(None, updated_data, **kwargs)
- self._update_attrs(server_data)
+ self.gitlab.http_delete(self.path, query_data={'name': name}, **kwargs)
class ProjectFile(SaveMixin, ObjectDeleteMixin, RESTObject):
@@ -1297,6 +1298,38 @@ class ProjectFile(SaveMixin, ObjectDeleteMixin, RESTObject):
"""
return base64.b64decode(self.content)
+ def save(self, branch, commit_message, **kwargs):
+ """Save the changes made to the file to the server.
+
+ The object is updated to match what the server returns.
+
+ Args:
+ branch (str): Branch in which the file will be updated
+ commit_message (str): Message to send with the commit
+ **kwargs: Extra options to send to the server (e.g. sudo)
+
+ Raise:
+ GitlabAuthenticationError: If authentication is not correct
+ GitlabUpdateError: If the server cannot perform the request
+ """
+ self.branch = branch
+ self.commit_message = commit_message
+ super(ProjectFile, self).save(**kwargs)
+
+ def delete(self, branch, commit_message, **kwargs):
+ """Delete the file from the server.
+
+ Args:
+ branch (str): Branch from which the file will be removed
+ commit_message (str): Commit message for the deletion
+ **kwargs: Extra options to send to the server (e.g. sudo)
+
+ Raises:
+ GitlabAuthenticationError: If authentication is not correct
+ GitlabDeleteError: If the server cannot perform the request
+ """
+ self.manager.delete(self.get_id(), branch, commit_message, **kwargs)
+
class ProjectFileManager(GetMixin, CreateMixin, UpdateMixin, DeleteMixin,
RESTManager):
@@ -1308,11 +1341,12 @@ class ProjectFileManager(GetMixin, CreateMixin, UpdateMixin, DeleteMixin,
_update_attrs = (('file_path', 'branch', 'content', 'commit_message'),
('encoding', 'author_email', 'author_name'))
- def get(self, file_path, **kwargs):
+ def get(self, file_path, ref, **kwargs):
"""Retrieve a single file.
Args:
- id (int or str): ID of the object to retrieve
+ file_path (str): Path of the file to retrieve
+ ref (str): Name of the branch, tag or commit
**kwargs: Extra options to send to the Gitlab server (e.g. sudo)
Raises:
@@ -1323,7 +1357,49 @@ class ProjectFileManager(GetMixin, CreateMixin, UpdateMixin, DeleteMixin,
object: The generated RESTObject
"""
file_path = file_path.replace('/', '%2F')
- return GetMixin.get(self, file_path, **kwargs)
+ return GetMixin.get(self, file_path, ref=ref, **kwargs)
+
+ @exc.on_http_error(exc.GitlabCreateError)
+ def create(self, data, **kwargs):
+ """Create a new object.
+
+ Args:
+ data (dict): parameters to send to the server to create the
+ resource
+ **kwargs: Extra options to send to the Gitlab server (e.g. sudo)
+
+ Returns:
+ RESTObject: a new instance of the managed object class built with
+ the data sent by the server
+
+ Raises:
+ GitlabAuthenticationError: If authentication is not correct
+ GitlabCreateError: If the server cannot perform the request
+ """
+
+ self._check_missing_create_attrs(data)
+ file_path = data.pop('file_path')
+ path = '%s/%s' % (self.path, file_path)
+ server_data = self.gitlab.http_post(path, post_data=data, **kwargs)
+ return self._obj_cls(self, server_data)
+
+ @exc.on_http_error(exc.GitlabDeleteError)
+ def delete(self, file_path, branch, commit_message, **kwargs):
+ """Delete a file on the server.
+
+ Args:
+ file_path (str): Path of the file to remove
+ branch (str): Branch from which the file will be removed
+ commit_message (str): Commit message for the deletion
+ **kwargs: Extra options to send to the Gitlab server (e.g. sudo)
+
+ Raises:
+ GitlabAuthenticationError: If authentication is not correct
+ GitlabDeleteError: If the server cannot perform the request
+ """
+ path = '%s/%s' % (self.path, file_path.replace('/', '%2F'))
+ data = {'branch': branch, 'commit_message': commit_message}
+ self.gitlab.http_delete(path, query_data=data, **kwargs)
@exc.on_http_error(exc.GitlabGetError)
def raw(self, file_path, ref, streamed=False, action=None, chunk_size=1024,
@@ -1348,7 +1424,7 @@ class ProjectFileManager(GetMixin, CreateMixin, UpdateMixin, DeleteMixin,
Returns:
str: The file content
"""
- file_path = file_path.replace('/', '%2F')
+ file_path = file_path.replace('/', '%2F').replace('.', '%2E')
path = '%s/%s/raw' % (self.path, file_path)
query_data = {'ref': ref}
result = self.gitlab.http_get(path, query_data=query_data,
@@ -1489,8 +1565,8 @@ class ProjectVariableManager(CRUDMixin, RESTManager):
_path = '/projects/%(project_id)s/variables'
_obj_cls = ProjectVariable
_from_parent_attrs = {'project_id': 'id'}
- _create_attrs = (('key', 'vaule'), tuple())
- _update_attrs = (('key', 'vaule'), tuple())
+ _create_attrs = (('key', 'value'), tuple())
+ _update_attrs = (('key', 'value'), tuple())
class ProjectService(GitlabObject):
@@ -2016,7 +2092,8 @@ class ProjectManager(CRUDMixin, RESTManager):
'request_access_enabled')
)
_list_filters = ('search', 'owned', 'starred', 'archived', 'visibility',
- 'order_by', 'sort', 'simple', 'membership', 'statistics')
+ 'order_by', 'sort', 'simple', 'membership', 'statistics',
+ 'with_issues_enabled', 'with_merge_requests_enabled')
class GroupProject(Project):
diff --git a/tools/build_test_env.sh b/tools/build_test_env.sh
index 96d341a..35a54c6 100755
--- a/tools/build_test_env.sh
+++ b/tools/build_test_env.sh
@@ -154,6 +154,6 @@ log "Installing into virtualenv..."
try pip install -e .
log "Pausing to give GitLab some time to finish starting up..."
-sleep 20
+sleep 30
log "Test environment initialized."
diff --git a/tools/py_functional_tests.sh b/tools/py_functional_tests.sh
index 0d00c5f..75bb761 100755
--- a/tools/py_functional_tests.sh
+++ b/tools/py_functional_tests.sh
@@ -18,4 +18,4 @@ setenv_script=$(dirname "$0")/build_test_env.sh || exit 1
BUILD_TEST_ENV_AUTO_CLEANUP=true
. "$setenv_script" "$@" || exit 1
-try python "$(dirname "$0")"/python_test.py
+try python "$(dirname "$0")"/python_test_v${API_VER}.py
diff --git a/tools/python_test.py b/tools/python_test_v3.py
index 62d6421..62d6421 100644
--- a/tools/python_test.py
+++ b/tools/python_test_v3.py
diff --git a/tools/python_test_v4.py b/tools/python_test_v4.py
new file mode 100644
index 0000000..ec3f0d3
--- /dev/null
+++ b/tools/python_test_v4.py
@@ -0,0 +1,341 @@
+import base64
+import time
+
+import gitlab
+
+LOGIN = 'root'
+PASSWORD = '5iveL!fe'
+
+SSH_KEY = ("ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDZAjAX8vTiHD7Yi3/EzuVaDChtih"
+ "79HyJZ6H9dEqxFfmGA1YnncE0xujQ64TCebhkYJKzmTJCImSVkOu9C4hZgsw6eE76n"
+ "+Cg3VwEeDUFy+GXlEJWlHaEyc3HWioxgOALbUp3rOezNh+d8BDwwqvENGoePEBsz5l"
+ "a6WP5lTi/HJIjAl6Hu+zHgdj1XVExeH+S52EwpZf/ylTJub0Bl5gHwf/siVE48mLMI"
+ "sqrukXTZ6Zg+8EHAIvIQwJ1dKcXe8P5IoLT7VKrbkgAnolS0I8J+uH7KtErZJb5oZh"
+ "S4OEwsNpaXMAr+6/wWSpircV2/e7sFLlhlKBC4Iq1MpqlZ7G3p foo@bar")
+DEPLOY_KEY = ("ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDFdRyjJQh+1niBpXqE2I8dzjG"
+ "MXFHlRjX9yk/UfOn075IdaockdU58sw2Ai1XIWFpZpfJkW7z+P47ZNSqm1gzeXI"
+ "rtKa9ZUp8A7SZe8vH4XVn7kh7bwWCUirqtn8El9XdqfkzOs/+FuViriUWoJVpA6"
+ "WZsDNaqINFKIA5fj/q8XQw+BcS92L09QJg9oVUuH0VVwNYbU2M2IRmSpybgC/gu"
+ "uWTrnCDMmLItksATifLvRZwgdI8dr+q6tbxbZknNcgEPrI2jT0hYN9ZcjNeWuyv"
+ "rke9IepE7SPBT41C+YtUX4dfDZDmczM1cE0YL/krdUCfuZHMa4ZS2YyNd6slufc"
+ "vn bar@foo")
+
+# login/password authentication
+gl = gitlab.Gitlab('http://localhost:8080', email=LOGIN, password=PASSWORD)
+gl.auth()
+token_from_auth = gl.private_token
+
+# token authentication from config file
+gl = gitlab.Gitlab.from_config(config_files=['/tmp/python-gitlab.cfg'])
+assert(token_from_auth == gl.private_token)
+gl.auth()
+assert(isinstance(gl.user, gitlab.v4.objects.CurrentUser))
+
+# settings
+settings = gl.settings.get()
+settings.default_projects_limit = 42
+settings.save()
+settings = gl.settings.get()
+assert(settings.default_projects_limit == 42)
+
+# user manipulations
+new_user = gl.users.create({'email': 'foo@bar.com', 'username': 'foo',
+ 'name': 'foo', 'password': 'foo_password'})
+users_list = gl.users.list()
+for user in users_list:
+ if user.username == 'foo':
+ break
+assert(new_user.username == user.username)
+assert(new_user.email == user.email)
+
+new_user.block()
+new_user.unblock()
+
+foobar_user = gl.users.create(
+ {'email': 'foobar@example.com', 'username': 'foobar',
+ 'name': 'Foo Bar', 'password': 'foobar_password'})
+
+assert gl.users.list(search='foobar').next().id == foobar_user.id
+usercmp = lambda x,y: cmp(x.id, y.id)
+expected = sorted([new_user, foobar_user], cmp=usercmp)
+actual = sorted(list(gl.users.list(search='foo')), cmp=usercmp)
+assert len(expected) == len(actual)
+assert len(gl.users.list(search='asdf')) == 0
+
+# SSH keys
+key = new_user.keys.create({'title': 'testkey', 'key': SSH_KEY})
+assert(len(new_user.keys.list()) == 1)
+key.delete()
+assert(len(new_user.keys.list()) == 0)
+
+# emails
+email = new_user.emails.create({'email': 'foo2@bar.com'})
+assert(len(new_user.emails.list()) == 1)
+email.delete()
+assert(len(new_user.emails.list()) == 0)
+
+new_user.delete()
+foobar_user.delete()
+assert(len(gl.users.list()) == 3)
+
+# current user key
+key = gl.user.keys.create({'title': 'testkey', 'key': SSH_KEY})
+assert(len(gl.user.keys.list()) == 1)
+key.delete()
+assert(len(gl.user.keys.list()) == 0)
+
+# groups
+user1 = gl.users.create({'email': 'user1@test.com', 'username': 'user1',
+ 'name': 'user1', 'password': 'user1_pass'})
+user2 = gl.users.create({'email': 'user2@test.com', 'username': 'user2',
+ 'name': 'user2', 'password': 'user2_pass'})
+group1 = gl.groups.create({'name': 'group1', 'path': 'group1'})
+group2 = gl.groups.create({'name': 'group2', 'path': 'group2'})
+
+p_id = gl.groups.list(search='group2').next().id
+group3 = gl.groups.create({'name': 'group3', 'path': 'group3', 'parent_id': p_id})
+
+assert(len(gl.groups.list()) == 3)
+assert(len(gl.groups.list(search='1')) == 1)
+assert(group3.parent_id == p_id)
+
+group1.members.create({'access_level': gitlab.Group.OWNER_ACCESS,
+ 'user_id': user1.id})
+group1.members.create({'access_level': gitlab.Group.GUEST_ACCESS,
+ 'user_id': user2.id})
+
+group2.members.create({'access_level': gitlab.Group.OWNER_ACCESS,
+ 'user_id': user2.id})
+
+# Administrator belongs to the groups
+assert(len(group1.members.list()) == 3)
+assert(len(group2.members.list()) == 2)
+
+group1.members.delete(user1.id)
+assert(len(group1.members.list()) == 2)
+member = group1.members.get(user2.id)
+member.access_level = gitlab.Group.OWNER_ACCESS
+member.save()
+member = group1.members.get(user2.id)
+assert(member.access_level == gitlab.Group.OWNER_ACCESS)
+
+group2.members.delete(gl.user.id)
+
+# hooks
+hook = gl.hooks.create({'url': 'http://whatever.com'})
+assert(len(gl.hooks.list()) == 1)
+hook.delete()
+assert(len(gl.hooks.list()) == 0)
+
+# projects
+admin_project = gl.projects.create({'name': 'admin_project'})
+gr1_project = gl.projects.create({'name': 'gr1_project',
+ 'namespace_id': group1.id})
+gr2_project = gl.projects.create({'name': 'gr2_project',
+ 'namespace_id': group2.id})
+sudo_project = gl.projects.create({'name': 'sudo_project'}, sudo=user1.name)
+
+assert(len(gl.projects.list(owned=True)) == 2)
+assert(len(gl.projects.list(search="admin")) == 1)
+
+# test pagination
+# FIXME => we should return lists, not RESTObjectList
+#l1 = gl.projects.list(per_page=1, page=1)
+#l2 = gl.projects.list(per_page=1, page=2)
+#assert(len(l1) == 1)
+#assert(len(l2) == 1)
+#assert(l1[0].id != l2[0].id)
+
+# project content (files)
+admin_project.files.create({'file_path': 'README',
+ 'branch': 'master',
+ 'content': 'Initial content',
+ 'commit_message': 'Initial commit'})
+readme = admin_project.files.get(file_path='README', ref='master')
+readme.content = base64.b64encode("Improved README")
+time.sleep(2)
+readme.save(branch="master", commit_message="new commit")
+readme.delete(commit_message="Removing README", branch="master")
+
+admin_project.files.create({'file_path': 'README.rst',
+ 'branch': 'master',
+ 'content': 'Initial content',
+ 'commit_message': 'New commit'})
+readme = admin_project.files.get(file_path='README.rst', ref='master')
+assert(readme.decode() == 'Initial content')
+
+data = {
+ 'branch': 'master',
+ 'commit_message': 'blah blah blah',
+ 'actions': [
+ {
+ 'action': 'create',
+ 'file_path': 'blah',
+ 'content': 'blah'
+ }
+ ]
+}
+admin_project.commits.create(data)
+
+tree = admin_project.repository_tree()
+assert(len(tree) == 2)
+assert(tree[0]['name'] == 'README.rst')
+blob_id = tree[0]['id']
+blob = admin_project.repository_raw_blob(blob_id)
+assert(blob == 'Initial content')
+archive1 = admin_project.repository_archive()
+archive2 = admin_project.repository_archive('master')
+assert(archive1 == archive2)
+
+# deploy keys
+deploy_key = admin_project.keys.create({'title': 'foo@bar', 'key': DEPLOY_KEY})
+project_keys = list(admin_project.keys.list())
+assert(len(project_keys) == 1)
+
+sudo_project.keys.enable(deploy_key.id)
+assert(len(sudo_project.keys.list()) == 1)
+sudo_project.keys.delete(deploy_key.id)
+assert(len(sudo_project.keys.list()) == 0)
+
+# labels
+label1 = admin_project.labels.create({'name': 'label1', 'color': '#778899'})
+label1 = admin_project.labels.get('label1')
+assert(len(admin_project.labels.list()) == 1)
+label1.new_name = 'label1updated'
+label1.save()
+assert(label1.name == 'label1updated')
+label1.subscribe()
+assert(label1.subscribed == True)
+label1.unsubscribe()
+assert(label1.subscribed == False)
+label1.delete()
+
+# milestones
+m1 = admin_project.milestones.create({'title': 'milestone1'})
+assert(len(admin_project.milestones.list()) == 1)
+m1.due_date = '2020-01-01T00:00:00Z'
+m1.save()
+m1.state_event = 'close'
+m1.save()
+m1 = admin_project.milestones.get(1)
+assert(m1.state == 'closed')
+
+# issues
+issue1 = admin_project.issues.create({'title': 'my issue 1',
+ 'milestone_id': m1.id})
+issue2 = admin_project.issues.create({'title': 'my issue 2'})
+issue3 = admin_project.issues.create({'title': 'my issue 3'})
+assert(len(admin_project.issues.list()) == 3)
+issue3.state_event = 'close'
+issue3.save()
+assert(len(admin_project.issues.list(state='closed')) == 1)
+assert(len(admin_project.issues.list(state='opened')) == 2)
+assert(len(admin_project.issues.list(milestone='milestone1')) == 1)
+assert(m1.issues().next().title == 'my issue 1')
+
+# tags
+tag1 = admin_project.tags.create({'tag_name': 'v1.0', 'ref': 'master'})
+assert(len(admin_project.tags.list()) == 1)
+tag1.set_release_description('Description 1')
+tag1.set_release_description('Description 2')
+assert(tag1.release['description'] == 'Description 2')
+tag1.delete()
+
+# triggers
+tr1 = admin_project.triggers.create({'description': 'trigger1'})
+assert(len(admin_project.triggers.list()) == 1)
+tr1.delete()
+
+# variables
+v1 = admin_project.variables.create({'key': 'key1', 'value': 'value1'})
+assert(len(admin_project.variables.list()) == 1)
+v1.value = 'new_value1'
+v1.save()
+v1 = admin_project.variables.get(v1.key)
+assert(v1.value == 'new_value1')
+v1.delete()
+
+# branches and merges
+to_merge = admin_project.branches.create({'branch': 'branch1',
+ 'ref': 'master'})
+admin_project.files.create({'file_path': 'README2.rst',
+ 'branch': 'branch1',
+ 'content': 'Initial content',
+ 'commit_message': 'New commit in new branch'})
+mr = admin_project.mergerequests.create({'source_branch': 'branch1',
+ 'target_branch': 'master',
+ 'title': 'MR readme2'})
+mr.merge()
+admin_project.branches.delete('branch1')
+
+try:
+ mr.merge()
+except gitlab.GitlabMRClosedError:
+ pass
+
+# stars
+admin_project.star()
+assert(admin_project.star_count == 1)
+admin_project.unstar()
+assert(admin_project.star_count == 0)
+
+# project boards
+#boards = admin_project.boards.list()
+#assert(len(boards))
+#board = boards[0]
+#lists = board.lists.list()
+#begin_size = len(lists)
+#last_list = lists[-1]
+#last_list.position = 0
+#last_list.save()
+#last_list.delete()
+#lists = board.lists.list()
+#assert(len(lists) == begin_size - 1)
+
+# namespaces
+ns = gl.namespaces.list(all=True)
+assert(len(ns) != 0)
+ns = gl.namespaces.list(search='root', all=True)[0]
+assert(ns.kind == 'user')
+
+# broadcast messages
+msg = gl.broadcastmessages.create({'message': 'this is the message'})
+msg.color = '#444444'
+msg.save()
+msg = gl.broadcastmessages.list(all=True)[0]
+assert(msg.color == '#444444')
+msg = gl.broadcastmessages.get(1)
+assert(msg.color == '#444444')
+msg.delete()
+assert(len(gl.broadcastmessages.list()) == 0)
+
+# notification settings
+settings = gl.notificationsettings.get()
+settings.level = gitlab.NOTIFICATION_LEVEL_WATCH
+settings.save()
+settings = gl.notificationsettings.get()
+assert(settings.level == gitlab.NOTIFICATION_LEVEL_WATCH)
+
+# services
+# NOT IMPLEMENTED YET
+#service = admin_project.services.get(service_name='asana')
+#service.active = True
+#service.api_key = 'whatever'
+#service.save()
+#service = admin_project.services.get(service_name='asana')
+#assert(service.active == True)
+
+# snippets
+snippets = gl.snippets.list(all=True)
+assert(len(snippets) == 0)
+snippet = gl.snippets.create({'title': 'snippet1', 'file_name': 'snippet1.py',
+ 'content': 'import gitlab'})
+snippet = gl.snippets.get(1)
+snippet.title = 'updated_title'
+snippet.save()
+snippet = gl.snippets.get(1)
+assert(snippet.title == 'updated_title')
+content = snippet.content()
+assert(content == 'import gitlab')
+snippet.delete()
+assert(len(gl.snippets.list()) == 0)